Efficient PNG optimization in Python with Oxipng

Optimizing images is crucial for enhancing web performance and reducing bandwidth usage. PNG files
are lossless and high-quality, yet they often ship with excess metadata and sub-optimal compression.
In this DevTip, we’ll show how to shrink PNGs from Python by calling the modern, Rust-powered Oxipng
engine through its actively maintained Python binding, pyoxipng
.
Why optimize PNG images?
Smaller images mean faster page loads, better Core Web Vitals, lower CDN bills, and happier users. On mobile connections, a significant reduction in file size can be the difference between a snappy interface and a bounce-inducing wait. Effective PNG optimization can often reduce file sizes by 15-40% without any loss of visual quality.
Optipng vs. Oxipng
OptiPNG is the classic, C-based command-line tool for lossless PNG compression. It's been a reliable choice for years. However, Oxipng is a modern, Rust rewrite designed for speed and efficiency. It's fully compatible with OptiPNG's lossless optimizations but adds multithreading, SIMD acceleration, and improved heuristics for potentially better compression ratios and faster execution.
The pyoxipng
library provides Pythonic access to the Oxipng Rust core, allowing you to leverage
its performance directly within your Python applications without needing to manage external
processes. While this post focuses on pyoxipng
due to its performance and active maintenance, the
optimization principles are similar to those of the original OptiPNG.
Prerequisites
Before you start, ensure you have the necessary tools installed:
# Check Python version (3.8+ recommended)
python --version
# Install pyoxipng and optional dependencies
pip install pyoxipng pillow numpy
- Python: Version 3.8 or newer is recommended.
- pyoxipng: The core library for PNG optimization (
pip install pyoxipng
). Version 9.1.0 or newer is advised. - Pillow: Used for image loading and manipulation in advanced examples (
pip install pillow
). - NumPy: Required for array handling when working with raw image data (
pip install numpy
).
Optimize a single PNG
Optimizing a single file is straightforward using the optimize
function:
import oxipng
try:
# Optimize 'input.png' and save it as 'output.png'
# Level ranges from 0 (fastest, least compression) to 6 (slowest, best compression). Default is 2.
oxipng.optimize('input.png', 'output.png', level=4)
print("Optimization successful!")
except oxipng.PngError as e:
print(f"Error optimizing PNG: {e}")
except FileNotFoundError:
print("Error: Input file not found.")
With default settings (level=2
), you can typically expect a 15–25% reduction in file size, often
completed in milliseconds. Increasing the level improves compression but takes longer.
Batch optimization with threads
For optimizing multiple images, using threads can significantly speed up the process, especially on
multi-core systems. pyoxipng
is thread-safe.
import oxipng
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
# Define source and destination directories
SRC_DIR = Path('images_source')
DST_DIR = Path('images_optimized')
DST_DIR.mkdir(exist_ok=True) # Create destination if it doesn't exist
# Find all PNG files in the source directory
png_files = list(SRC_DIR.glob('*.png'))
# Function to optimize a single PNG file
def optimize_png(source_path: Path) -> None:
dest_path = DST_DIR / source_path.name
try:
oxipng.optimize(source_path, dest_path, level=4)
print(f'✓ Optimized: {source_path.name}')
except oxipng.PngError as e:
print(f'✗ Failed: {source_path.name} - {e}')
except Exception as e:
print(f'✗ Error processing {source_path.name}: {e}')
# Use a threadpoolexecutor for parallel processing
# Adjust max_workers based on your CPU cores (os.cpu_count() is a good starting point)
num_workers = os.cpu_count() or 4 # Default to 4 if cpu_count() fails
with ThreadPoolExecutor(max_workers=num_workers) as executor:
# Map the optimize_png function to all found PNG files
# Use list() to ensure all futures complete before moving on
list(executor.map(optimize_png, png_files))
print("\nBatch optimization complete.")
Set the max_workers
in ThreadPoolExecutor
(e.g., to os.cpu_count()
) to maximize throughput. On
an 8-core machine, processing hundreds of small PNGs can take just a few seconds.
Choosing the right optimization level
pyoxipng
offers optimization levels from 0 to 6. Higher levels yield smaller files but require
more processing time.
Level | Typical Savings | Relative Speed | When to Use |
---|---|---|---|
0 | 5-10% | Very Fast | Quick checks, dev builds where speed is key |
2 (default) | 15-25% | Fast | General web assets, good balance |
4 | 20-35% | Moderate | Production deployments, aiming for size |
6 | 25-40% | Slowest | Final archives, one-off optimizations |
Choose the level that best balances compression needs with processing time constraints for your use case.
Preserving or stripping metadata
By default, pyoxipng
might strip some non-essential metadata chunks. You can control this behavior
using the strip
parameter:
import oxipng
try:
# Strip all possible metadata for maximum size reduction
oxipng.optimize(
'input_with_meta.png',
'output_stripped.png',
level=4,
strip=oxipng.Strip.all() # Strip all ancillary chunks (like text, exif, etc.)
)
print("Optimized with maximum metadata stripping.")
# Preserve all metadata
oxipng.optimize(
'input_with_meta.png',
'output_preserved.png',
level=4,
strip=oxipng.Strip.none() # Keep all metadata chunks
# preserve_attrs=True # Optionally preserve file system attributes (mtime, etc.)
)
print("Optimized while preserving metadata.")
except oxipng.PngError as e:
print(f"Error during optimization: {e}")
except FileNotFoundError:
print("Error: Input file 'input_with_meta.png' not found.")
Use strip=oxipng.Strip.all()
to remove maximum metadata for the smallest file size. Use
strip=oxipng.Strip.safe()
to keep essential metadata like color profiles but remove others. Use
strip=oxipng.Strip.none()
(or omit strip
) to preserve all metadata chunks. preserve_attrs=True
keeps file system attributes like modification time, which is usually not necessary for web
delivery.
Advanced: in-memory optimization pipelines
If you're generating or manipulating images in memory (e.g., using Pillow), you can optimize the raw pixel data directly without writing intermediate files. This requires providing image dimensions and color type information.
import oxipng
import numpy as np
from PIL import Image
from pathlib import Path
try:
# Load an image using Pillow and convert to a suitable format (e.g., RGB or RGBA)
img = Image.open('logo_source.png')
# Ensure image mode is compatible (e.g., RGB, RGBA, L for grayscale)
if img.mode not in ('RGB', 'RGBA', 'L'):
img = img.convert('RGBA') # Convert to RGBA if needed
# Convert image to a NumPy array
img_array = np.array(img, dtype=np.uint8)
height, width = img_array.shape[:2] # Get height and width
# Get raw bytes from the NumPy array
raw_bytes = img_array.tobytes()
# Define the color type based on Pillow's image mode
if img.mode == 'RGB':
# For RGB, specify transparency key if needed, e.g., black (0,0,0)
color_type = oxipng.ColorType.rgb(None) # None means no specific color is transparent
elif img.mode == 'RGBA':
color_type = oxipng.ColorType.rgba()
elif img.mode == 'L': # Grayscale
color_type = oxipng.ColorType.grayscale()
else:
raise ValueError(f"Unsupported image mode for Oxipng: {img.mode}")
# Create a RawImage object
raw_image = oxipng.RawImage(
data=raw_bytes,
width=width,
height=height,
color_type=color_type
)
# Optimize the raw image data in memory (level 6 for max compression)
optimized_png_bytes = raw_image.create_optimized_png(level=6)
# Save the optimized bytes to a file
output_path = Path('logo_optimized_from_memory.png')
output_path.write_bytes(optimized_png_bytes)
print(f"Successfully optimized image from memory and saved to {output_path}")
except FileNotFoundError:
print("Error: Source image file 'logo_source.png' not found.")
except ImportError:
print("Error: Pillow or NumPy not installed. Run 'pip install pillow numpy'")
except oxipng.PngError as e:
print(f"Error during in-memory optimization: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
This approach is useful for integrating optimization directly into image processing workflows, avoiding disk I/O for intermediate steps.
Minimal benchmark script
To get a feel for the performance on your own images, use a simple benchmark script:
import time
import oxipng
import shutil
import pathlib
import os
# Define the source file for the benchmark
source_file = pathlib.Path('sample.png')
# Check if the source file exists
if not source_file.exists():
print(f"Error: Benchmark file '{source_file}' not found. Please create a sample PNG file named 'sample.png'.")
exit()
target_file = pathlib.Path('sample_optimized_benchmark.png')
# Copy the source file to keep the original intact
shutil.copy(source_file, target_file)
original_size = source_file.stat().st_size
print(f"Optimizing '{target_file.name}' (Level 4)...")
start_time = time.perf_counter()
try:
# Optimize the copied file in place
oxipng.optimize(target_file, target_file, level=4)
end_time = time.perf_counter()
optimized_size = target_file.stat().st_size
size_diff = original_size - optimized_size
time_taken = end_time - start_time
print(f"Original size: {original_size / 1024:.1f} KiB")
print(f"Optimized size: {optimized_size / 1024:.1f} KiB")
if original_size > 0:
print(f"Saved: {size_diff / 1024:.1f} KiB ({size_diff / original_size * 100:.1f}% reduction)")
else:
print("Saved: 0.0 KiB (Original size was 0)")
print(f"Time taken: {time_taken:.3f} seconds")
except oxipng.PngError as e:
print(f"Optimization failed: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
finally:
# Clean up the copied benchmark file
if target_file.exists():
try:
os.remove(target_file)
print(f"Cleaned up '{target_file.name}'.")
except OSError as e:
print(f"Error removing benchmark file '{target_file.name}': {e}")
Run this script with various PNG files and optimization levels (level=...
) to gather real-world
performance data relevant to your assets.
Troubleshooting
Here are some common issues and potential solutions when using pyoxipng
:
Symptom | Likely Cause | Fix |
---|---|---|
ModuleNotFoundError: No module named 'oxipng' |
pyoxipng not installed or venv inactive |
Activate virtual environment (source venv/bin/activate or .\venv\Scripts\activate ) then pip install pyoxipng |
oxipng.PngError: Not a PNG file |
Input file is corrupted or not PNG | Validate input files; ensure they are valid PNGs before processing. Use libraries like Pillow to check format if needed. |
oxipng.PngError: Failed to parse header (IHDR) |
Input file is corrupted or not PNG | Check file integrity. Skip files that fail validation. |
High memory usage or crashes on large images | High level or too many threads | Lower optimization level , reduce max_workers in ThreadPoolExecutor , or process large files sequentially. Consider system memory limits. |
FileNotFoundError |
Input file path is incorrect | Verify that the input file path exists and is accessible by the script. Check relative vs. absolute paths. |
Error handling Template
Robust applications should include error handling to manage potential issues during optimization:
import oxipng
import sys
from pathlib import Path
input_path = Path('image.png')
output_path = Path('image_optimized.png')
try:
print(f"Attempting to optimize '{input_path}' to '{output_path}'...")
oxipng.optimize(input_path, output_path, level=4)
print(f"Successfully optimized.")
except oxipng.PngError as exc:
# Handle specific Oxipng errors (e.g., invalid PNG format, processing errors)
sys.exit(f'Optimization failed for {input_path}: {exc}')
except FileNotFoundError:
# Handle case where the input file doesn't exist
sys.exit(f'Error: Input file not found at {input_path}')
except PermissionError:
# Handle case where script lacks permissions to read input or write output
sys.exit(f'Error: Permission denied for file operations involving {input_path} or {output_path}')
except Exception as exc:
# Catch any other unexpected errors during the process
sys.exit(f'An unexpected error occurred during optimization: {exc}')
This structure helps prevent crashes and provides informative messages if optimization fails for various reasons.
Managed alternative
If you prefer not to manage the optimization process yourself, cloud-based services like Transloadit can handle it automatically. Transloadit's 🤖 /image/optimize Robot can optimize PNGs (and other formats) as part of an upload and processing workflow:
{
"steps": {
"optimized": {
"robot": "/image/optimize",
"use": ":original",
"priority": "compression-ratio",
"progressive": false,
"preserve_meta_data": true
}
}
}
This Assembly Step takes the original uploaded file (:original
) and applies
optimization using the /image/optimize
Robot, prioritizing the compression ratio while
preserving metadata.
Wrap-up
Using pyoxipng
, you can easily integrate high-performance, lossless PNG optimization into your
Python projects. Whether you're batch-processing assets during a build step, optimizing user uploads
on the fly, or integrating with complex image manipulation pipelines, pyoxipng
provides a fast and
efficient solution based on the modern Oxipng engine. This helps you deliver leaner web pages and
applications with improved loading times. For a fully managed solution, consider using a service
like Transloadit to handle image optimization automatically as part of your file processing
workflows.