DEV Community

Denny Guide
Denny Guide

Posted on Edited on

Batch-Converting 500 PNGs to JPG: I Timed My Pillow Script Against the Alternatives

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
    )
Enter fullscreen mode Exit fullscreen mode

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.

Update: per-file p50/p95, because a reader rightly pushed back

A commenter noted my batch average answers "is the whole folder fast" but not "will this file be fast," and predicted the tails would live in the alpha branch — compositing cost scales with pixel count in a way plain conversion doesn't. Half right, and the half that was wrong turned out to be the interesting part.

Re-ran with per-file timing: same machine, noise images, n=60 per cell, four size buckets, alpha and opaque separated:

Bucket MP Alpha? p50 p95
16×16 0.0003 yes 0.2 ms 0.7 ms
16×16 0.0003 no 0.15 ms 0.5 ms
240×240 0.058 yes 2.2 ms 2.5 ms
240×240 0.058 no 1.9 ms 2.2 ms
800×600 0.48 yes 14.3 ms 16.0 ms
800×600 0.48 no 12.3 ms 15.9 ms
4000×3000 12 yes 305 ms 330 ms
4000×3000 12 no 296 ms 307 ms

What the table actually says:

  • Megapixels, not alpha, own the tail. Per-file time spans 0.2 ms → 305 ms (~1500×) across buckets; within a bucket, alpha vs opaque differs by 3–16%. Your p95 is decided by "how big is your biggest file."
  • The alpha tax shrinks as files grow. +15–16% at icon sizes, where the extra paste-onto-white is proportionally large, but only +3% at 12 MP, where JPEG encode dominates everything else. "Alpha files are slow" is only true for files that are fast anyway.
  • Within-bucket variance is tight — p95 ≈ 1.1× p50 everywhere except the 16px bucket, where the absolute numbers are sub-millisecond noise. The spread in my original 6.7 s figure came from the file mix, not flaky per-file behavior.
  • For sizing your own batch on comparable hardware: budget ~25–40 ms per megapixel, and treat alpha as a rounding error unless everything you own is tiny icons.

Updated Oct 2026: added per-file p50/p95 by alpha channel and size bucket after feedback in the comments.

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 (8)

Collapse
 
dennyguide profile image
Denny Guide •

@devenquan Fair point — batch averages and per-file truth answer two different questions, and your 10k-prompt test is a clean demonstration of that (5% off overall, 40% off per item, both "true").

You're right about where the per-file cost hides in my run: the ~20% of inputs with an alpha channel go through the paste-onto-white + convert branch, while the other 80% skip straight to convert. That subset is also the one people usually care about (logos, icons), so the batch average undersells exactly the files that matter.

I'll re-run with the alpha subset timed separately and add a per-file p50/p95 column to the table — will update the post when that's done.

Collapse
 
devenquan profile image
Zhang Enquan •

Most authors defend the aggregate - updating the table is the better move.

One heads-up for the p50/p95 columns: the tails will carry the story. In my run the median looked acceptable while p5 to p95 spanned -24% to +47%, and short items were the worst offenders. If your alpha subset behaves the same way, paste-onto-white is probably also your highest-variance branch - compositing cost scales with pixel dimensions in a way plain conversion doesn't, so a 16x16 icon and a 4000px logo can both have alpha and wildly different timings.

Looking forward to the updated table.

Collapse
 
dennyguide profile image
Denny Guide •

Good catch on the pixel-dimension scaling — compositing is O(W×H) while the plain convert path is dominated by decode/encode, so an alpha 4000px logo pays far more than an alpha 16×16 icon even though both count as "alpha files" in my subset.

Which means my planned split isn't granular enough: alpha-vs-no-alpha tells you which branch runs, but pixel count sets the cost within the branch. So the re-run will bucket by megapixels too (roughly <0.1MP / 0.1–1MP / >1MP) with p50/p95 per bucket — otherwise the p95 of the whole run collapses into "the biggest logo," which tells readers nothing they can apply to their own files.

The original table answered "is Pillow fast enough on average"; the update should answer "will it be fast on the files I actually have" — and that's the question that matters. Update coming once the runs finish.

Collapse
 
devenquan profile image
Zhang Enquan •

75 images/s is a batch number, and batch numbers lie about single files. I got burned by exactly that shape.

I tokenized 10,000 real prompts to test the "4 characters per token" rule. Across the whole batch the estimate was off by 5.08% - fine. Per item, 40.5% were off by more than 20%, ranging from -24% to +47%. Same data, two true sentences, opposite conclusions, depending on whether you're buying 10,000 or shipping one.

Your run has the same split hiding in it. 744.7 MB in, 171.7 MB out is one ratio, but you already flagged the swing: 23% on noise inputs, 60-70% on real screenshots and photos. That's a 3x spread between files inside one batch. Anyone whose actual question is "does this one file clear a 25 MB cap" is reading a number that answers something else.

One thing worth adding to the table: break out the 20% with alpha. That paste+convert branch is where the per-file cost lives, and it's the subset people actually care about (logos).

Collapse
 
dennyguide profile image
Denny Guide •

@kyisaiah47 Agreed — for a hard per-file cap like a 25 MB CMS upload limit, the number to log is the largest single output, not the folder total. A batch that "succeeds" on average can still emit one file over the line.

At my test resolution (800×600) a single JPEG is inherently far below 25 MB, so the cap never bit. But that limit gets real the moment you feed camera-original resolutions (4000×3000-class photos), and that's exactly when max-single-output is the right guard metric. Adding it to the results table.

Collapse
 
dennyguide profile image
Denny Guide •

@devenquan Table's up — and thanks for pushing this; the batch average genuinely undersold it.

You called the mechanism right (compositing scales with pixel count), but the data says megapixels, not the alpha branch, own the tail: per-file time spans 0.2 ms (16px icon) to 305 ms (12 MP file), ~1500×, while alpha vs. opaque differs by only 3–16% within a bucket.

The twist I didn't expect: the alpha tax is biggest on small files (+16% at icon sizes) and nearly vanishes at 12 MP (+3%, JPEG encode dominates). So "alpha files are slow" is only true for files that are fast anyway. Your p95 turned out to be "how big is your biggest file."

Full table (p50/p95 by megapixel bucket × alpha) is in the updated post, plus a ~25–40 ms/MP rule of thumb for sizing batches.

Collapse
 
devenquan profile image
Zhang Enquan •

"Your p95 turned out to be how big is your biggest file" is the sentence that survives — I've seen the same shape in token counting, where one input class owns the tail and the average describes nobody.

What your twist actually buys is ordering: megapixels first, because that sets the scale 1500x wide; alpha second, and only at the small end (+16% where the absolute cost is trivial anyway). So the practical rule for anyone sizing a batch is to ignore alpha until their files are small enough that ignoring it doesn't matter — the kind of conclusion that only falls out when someone bothers to bucket.