Every few months I get the same folder: a few hundred PNGs exported from a design tool, all needing to be JPG before they can go into a CMS that has a hard 25 MB per-image cap. PNGs of screenshots and photos are heavy, and nobody on the content team is going to open an image editor 500 times.
So I have a script for this. But the last time I posted it, someone asked the obvious question: "is that actually faster than just using an online converter?" Fair. Let's measure it.
The task
- 500 PNG files, 800×600
- About 20% have an alpha channel (logos/icons), the rest are opaque
- Target: JPG, quality 85, white background where alpha gets flattened
- Don't touch the source files
The script
import os
from PIL import Image
SRC, DST = "src", "out"
os.makedirs(DST, exist_ok=True)
for name in sorted(os.listdir(SRC)):
if not name.lower().endswith(".png"):
continue
img = Image.open(os.path.join(SRC, name))
# Gotcha 1: JPEG has no alpha channel. RGBA/LA/P images must be
# flattened onto a background first, or Pillow raises
# "cannot write mode RGBA as JPEG".
if img.mode in ("RGBA", "LA", "P"):
rgba = img.convert("RGBA")
bg = Image.new("RGB", rgba.size, (255, 255, 255))
bg.paste(rgba, mask=rgba.split()[-1]) # alpha as the mask
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
# Gotcha 2: write to a different directory, never in place.
# A buggy quality setting shouldn't be able to destroy originals.
img.save(
os.path.join(DST, os.path.splitext(name)[0] + ".jpg"),
"JPEG",
quality=85,
optimize=True, # ~5-10% smaller, costs a bit of CPU
)
The three gotchas, because they always bite someone
1. Alpha flattening. The naive img.convert("RGB") on an RGBA image doesn't composite onto a background — it just drops the alpha channel. Transparent areas turn black, and you find out after the batch finished. The paste with the alpha band as mask is the fix; white is usually what you want for web, but for dark sites use a dark background.
2. Palette mode (P). GIF-style indexed PNGs show up in the wild more than you'd think, and they're in the same "can't save directly as JPEG" bucket. The mode check above catches them.
3. Never convert in place. Same input/output directory plus a glob that matches both .png and .jpg is how people destroy their originals. Separate out/ folder, always.
The numbers
Ran on my M-series MacBook, Python 3.13, Pillow 12.2:
| Metric | Result |
|---|---|
| Files | 500 |
| Wall time | 6.7 s |
| Throughput | ~75 images/s |
| Input total | 744.7 MB |
| Output total | 171.7 MB (23% of input) |
Worst-case input, by the way: these were noise images, which PNG compresses terribly. With real screenshots and photos the ratio is usually closer to 60–70%, because PNG is even more hopeless on photographic content than on noise.
CPU during the run: one core pegged, the rest idle. You could parallelize with a ProcessPoolExecutor and probably hit 4–6× that, but at 6.7 seconds for 500 images, why would you.
When the script is the wrong tool
Honesty time — "I have a script" is not always the right answer:
- You're on someone else's machine. Installing Python to convert one folder is using a cannon to kill a mosquito.
- It's a one-off. If you'll do this twice a year, writing and debugging a script is slower than the task itself, even the first time.
- A non-technical teammate needs it. "Run my Python script" is not a deliverable. "Open this page, drop the folder" is.
For those cases I keep a browser bookmark around — recently I've been using iLoveConvert's PNG to JPG tool for exactly this shape of problem: no signup, files auto-deleted after 24 hours, and it takes a whole folder in one drop. It's obviously not beating 75 images/s on my own hardware, but it doesn't need to — it just needs to work on any machine in ten seconds.
Rule of thumb
- Recurring batch + your own machine → script. It's faster, free, offline, and you control the quality settings.
- One-off, foreign machine, or handing to someone else → online tool. The 30 seconds you save by not fiddling matters more than raw throughput.
The script above is deliberately boring. Boring is good: it's the version that still runs in three years when Pillow has renamed something else.
Top comments (0)