Efficient PNG optimization in Python with Oxipng
Use pyoxipng to compress a PNG in memory, then save it only if the destination does not already
exist. This walkthrough gives you a script for individual files and small batches, with measured
sizes, nonzero exit codes on failure, and a separate check that decoded pixels stay unchanged.
OptiPNG vs. Oxipng
OptiPNG and
Oxipng are separate PNG optimizers. The Python package here,
pyoxipng, wraps Oxipng’s Rust library and imports as
oxipng. You do not need an OptiPNG executable or a subprocess to call it.
Decide what to preserve
Lossless optimization can change a PNG’s compression, palette, or color type while preserving its decoded pixels. It does not promise a particular percentage reduction. An already compact file may not shrink at all.
The example keeps source files intact, refuses existing destinations, and does not request metadata
stripping. It also leaves transparent pixel colors unchanged with optimize_alpha=False. Use it
for local static PNG assets in directories you control. The pixel check below covers 8-bit RGB and
RGBA inputs; it does not validate animation, 16-bit precision, or color-managed appearance.
Prerequisites
The commands below use Bash and Python 3.12 with venv and pip. They were tested on Linux with
Python 3.12.13 and pyoxipng 9.1.1. Start from a directory where png-demo does not yet exist:
mkdir png-demo &&
cd png-demo &&
python3.12 -m venv .venv &&
.venv/bin/python -m pip install --only-binary=:all: pyoxipng==9.1.1 Pillow==12.3.0
The && chain stops setup if a step fails, including an existing png-demo directory. The
--only-binary option requires a compatible wheel instead of starting a Rust source build.
PyPI lists the available wheels. Pillow is used
only for the pixel check, not for optimization. Run the remaining commands from png-demo.
Optimize a single PNG
Save the following as optimize_png.py. It passes encoded PNG bytes to
optimize_from_memory,
so it does not rebuild the image from a Pillow or NumPy pixel array.
import argparse
from pathlib import Path
import sys
from time import perf_counter
import oxipng
def optimize_bytes(data: bytes, level: int) -> bytes:
candidate = oxipng.optimize_from_memory(
data,
level=level,
strip=oxipng.StripChunks.none(),
optimize_alpha=False,
)
return candidate if len(candidate) < len(data) else data
def optimize_file(source: Path, destination: Path, level: int) -> None:
start = perf_counter()
original = source.read_bytes()
optimized = optimize_bytes(original, level)
# Exclusive creation fails even for a dangling destination symlink.
output = destination.open("xb")
try:
with output:
output.write(optimized)
except OSError:
# Only remove the file this invocation successfully created.
destination.unlink()
raise
saved = len(original) - len(optimized)
print(
f"{source.name}: {len(original)} -> {len(optimized)} bytes; "
f"saved {saved} bytes; {perf_counter() - start:.3f} s"
)
def main() -> int:
parser = argparse.ArgumentParser(description="Optimize PNGs without overwriting outputs")
parser.add_argument("source", type=Path, help="PNG file or directory")
parser.add_argument("destination", type=Path, help="New PNG file or output directory")
parser.add_argument("--level", type=int, choices=range(7), default=2)
args = parser.parse_args()
try:
source = args.source.resolve(strict=True)
if source.is_dir():
if source == args.destination.resolve():
raise ValueError("Source and output directories must differ")
files = sorted(
path for path in source.iterdir()
if path.suffix.lower() == ".png" and not path.is_symlink() and path.is_file()
)
if not files:
raise ValueError("No PNG files found")
args.destination.mkdir(exist_ok=True)
pairs = [(path, args.destination / path.name) for path in files]
else:
pairs = [(source, args.destination)]
except (OSError, ValueError) as error:
parser.exit(1, f"Error: {error}\n")
failed = False
for input_path, output_path in pairs:
try:
optimize_file(input_path, output_path, args.level)
except (OSError, oxipng.PngError) as error:
print(f"Failed {input_path.name}: {type(error).__name__}: {error}", file=sys.stderr)
failed = True
return int(failed)
if __name__ == "__main__":
sys.exit(main())
Place a PNG named input.png beside the script, then run:
.venv/bin/python optimize_png.py input.png output.png
On success, it prints the input and output byte counts, bytes saved, and elapsed seconds including
file I/O. A result of zero bytes saved is valid: the script writes a copy of the original when the
candidate is not smaller. Running the same command again reports FileExistsError, exits with
status 1, and leaves output.png intact. Using the input itself as the destination also fails
without changing it.
Python’s "xb" mode makes the
no-overwrite decision when creating the file. A corrupt or missing input fails before that point.
If writing or closing the newly created file raises OSError, the script attempts to remove that
partial output. This is a local script, not an atomic publication mechanism: a forced termination
can leave a partial file, and another process should not consume the destination until success.
Optimize a directory
Place your PNGs in images_source, then use the same script:
.venv/bin/python optimize_png.py images_source images_optimized --level 2
It creates images_optimized if needed and processes only immediate regular files ending in
.png, case-insensitively. It skips subdirectories and symlink entries. Spaces and literal %
characters in filenames are supported; for a command-line path beginning with -, use a ./
prefix or put -- before the positional arguments.
Each existing output is a failure for that file. Other files still run, successful outputs remain, and the batch exits with status 1 if any file failed. A missing source directory, an empty PNG inventory, or identical source and output directories fails before processing. To rerun the whole batch, choose a fresh output directory.
This loop processes one image at a time to limit how many decoded images are resident at once. Oxipng itself supports multithreading; adding a Python thread pool is not a demonstrated speedup for this workload. The input bytes, output bytes, and optimizer’s working memory must still fit in memory for each image.
Choosing the right optimization level
The binding accepts levels from 0 through 6, with 2 as its default. These are search presets, not quality settings or guaranteed savings. Start with 2 and compare results on your own assets.
| Level | Try it for |
|---|---|
| 0 | A quick baseline |
| 2 | Routine asset builds |
| 4 | A comparison with more search effort |
| 6 | A comparison when runtime matters less |
Measure without creating benchmark files
Save this as benchmark_png.py beside optimize_png.py, then run
.venv/bin/python benchmark_png.py input.png. It reads one file, tries four levels, and writes
only to the terminal. There is no temporary output filename to collide with your files.
import argparse
from pathlib import Path
import sys
from time import perf_counter
import oxipng
from optimize_png import optimize_bytes
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
args = parser.parse_args()
try:
data = args.source.read_bytes()
for level in (0, 2, 4, 6):
start = perf_counter()
result = optimize_bytes(data, level)
print(f"level={level}: {len(data)} -> {len(result)} bytes; {perf_counter() - start:.3f} s")
except (OSError, oxipng.PngError) as error:
sys.exit(f"Benchmark failed: {error}")
Each level starts from the original bytes. Timing includes optimization but excludes the initial file read. Repeat the measurement on representative files; one timing includes warm-up and system load effects. A higher preset need not produce a smaller result for every image.
Preserving or stripping metadata
StripChunks.none() requests no metadata
stripping. That is different from preserving every chunk byte-for-byte: Oxipng can
remove chunks made invalid by a color-type or bit-depth change.
The script also creates a new filesystem file, so it does not copy the source file’s timestamps
or permissions.
StripChunks.safe() removes metadata deemed unnecessary for rendering, including text comments.
StripChunks.all() can also remove color profiles and animation chunks. Do not substitute either
when your task requires retaining that information. Neither a smaller file nor an equal pixel
buffer proves that metadata survived. Inspect the particular fields your application needs.
Compare decoded pixels
Save this as check_pixels.py and run .venv/bin/python check_pixels.py input.png output.png.
For static 8-bit RGB/RGBA source PNGs, it compares dimensions and decoded RGBA bytes, including
color values under fully transparent pixels. An optimized file can use a palette, so the two
images are decoded into a common mode before comparison.
import argparse
import sys
from PIL import Image
parser = argparse.ArgumentParser()
parser.add_argument("source")
parser.add_argument("output")
args = parser.parse_args()
try:
with Image.open(args.source) as before, Image.open(args.output) as after:
if before.format != "PNG" or after.format != "PNG":
raise ValueError("Both files must be PNGs")
if before.n_frames != 1 or after.n_frames != 1:
raise ValueError("This check covers static PNGs only")
if before.mode not in ("RGB", "RGBA"):
raise ValueError("Use an 8-bit RGB or RGBA source for this check")
if before.size != after.size or before.convert("RGBA").tobytes() != after.convert("RGBA").tobytes():
raise ValueError("Decoded pixels differ")
print("Decoded RGBA pixels match")
except (OSError, ValueError) as error:
sys.exit(f"Pixel check failed: {error}")
Pillow’s tobytes()
returns decoded pixel data. This comparison does not apply ICC profiles or compare text, EXIF,
resolution, or other metadata. Supply 8-bit sources as stated: Pillow’s RGB mode alone does not
establish the original PNG’s bit depth.
Troubleshooting
| Error | Action |
|---|---|
ModuleNotFoundError | Use the project’s .venv/bin/python and repeat the installation step there. |
FileExistsError | Choose a new destination; inspect an old output before removing it yourself. |
FileNotFoundError | Check the source and the output’s parent directory. |
PngError | Check that the input is a complete, valid PNG. |
PermissionError | Choose a readable source and writable output directory. |
A failed batch can leave successful outputs alongside failures. Read stderr for the affected filenames; the exit status describes the batch, not a rollback. If memory usage is too high, keep the loop sequential and test smaller images before increasing the optimization level.
Managed alternative
For an upload workflow managed by Transloadit, see the /image/optimize documentation and choose its metadata policy separately from the local script’s settings.
