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.