Every compression press has a point past which more force buys almost nothing. zlib has one too, and on real gallery PNGs it sits at level 3.
Four real 1024 by 1536 gallery files, re-encoded at every zlib level with the clock running. The default is not the problem. The people cranking it to 9 are.
Somewhere in every image pipeline there is a line that says compress_level=9, and nobody remembers putting it there. It looks free. It is lossless, after all, so the only thing it can possibly cost is time, and time is cheap until you are re-encoding a gallery.
So four real gallery PNGs went through every zlib level this morning with a timer attached. All four are 1024 by 1536, all four are photographic AI portraits, and all four are the kind of file this site actually serves.
Nothing synthetic, nothing cropped to make a point. These are four PNGs pulled straight off the gallery, opened, converted to RGB and re-encoded. Every output was byte compared back to the source pixels, so every number below is lossless.
| File | Dimensions | Mode | On disk |
|---|---|---|---|
| elise.png | 1024 x 1536 | RGB | 2,712,717 |
| ashleybank2.png | 1024 x 1536 | RGB | 2,815,970 |
| bestever.png | 1024 x 1536 | RGB | 2,101,903 |
| sienna5.png | 1024 x 1536 | RGB | 2,599,726 |
| Total | 10,230,316 |
One thing worth noting before the sweep, because it is a common assumption that turned out to be wrong here: not one of these four carries an alpha channel. They are already RGB. Stripping alpha is the first advice everyone gives about PNG size and on this gallery it saves exactly zero bytes, because there is nothing to strip.
Six encodes per file. Levels 0, 1, 3, 6 and 9, plus Pillow's optimize=True, which forces level 9 and additionally searches the filter space. Totals across all four files, with encode time measured on the same machine back to back.
| Setting | Total bytes | Share of original | Encode time, per image |
|---|---|---|---|
| compress_level=0 | 18,887,020 | 184.6% | 172 ms |
| compress_level=1 | 15,238,725 | 149.0% | 317 ms |
| compress_level=3 | 10,345,066 | 101.1% | 349 ms |
| compress_level=6 (default) | 10,278,313 | 100.5% | 513 ms |
| compress_level=9 | 9,984,790 | 97.6% | 2,299 ms |
| optimize=True | 9,783,778 | 95.6% | 2,515 ms |
At level 0 all four files came out at exactly 4,721,755 bytes. Identical, to the byte, on four different images. That is not a bug, it is arithmetic: 1024 times 1536 times 3 channels is 4,718,592 bytes of raw pixel data, plus one filter byte per row for 1,536 more, plus the header and chunk structure. Level 0 stores rather than compresses, so the image content is irrelevant and only the dimensions matter.
It is also 84.6 percent larger than the original files, which is a useful reminder of how much work zlib is doing before anyone starts tuning it.
The interesting jump is not at the top of the range. It is between 1 and 3.
| Step | Bytes saved | Extra time per image | Bytes per extra millisecond |
|---|---|---|---|
| 0 to 1 | 3,648,295 | 145 ms | 6,290 |
| 1 to 3 | 4,893,659 | 32 ms | 38,232 |
| 3 to 6 | 66,753 | 164 ms | 102 |
| 6 to 9 | 293,523 | 1,786 ms | 41 |
| 9 to optimize | 201,012 | 216 ms | 233 |
Level 1 to level 3 returns roughly 38,000 bytes for every extra millisecond of encode time. Level 6 to level 9 returns 41. That is a factor of nine hundred, and it is the reason a default of 6 is a perfectly sensible default and a hardcoded 9 usually is not.
The one genuine surprise sits in the last row. Jumping from plain level 9 to optimize=True costs only another 216 milliseconds, because the file is already being deflated at maximum, and it returns 201,012 bytes. Per millisecond that is more than five times better than the 6 to 9 step it sits on top of. If you are already paying for level 9, you are leaving bytes on the table by not passing optimize=True.
Three practical readings, and none of them require rewriting a pipeline.
If encoding happens once and serving happens forever, use optimize=True and stop thinking about it. 2.5 seconds per image is nothing against a file that will be requested for years, and 95.6 percent of the original beats 100.5 percent every time.
If encoding is in a request path, use level 3. It lands within 0.6 percent of the default at two thirds of the time, and within 3.5 percent of the best result at one seventh of it.
Never leave level 0 or 1 in a pipeline by accident. Both produce files larger than the source. Level 1 in particular looks like a reasonable low setting and ships 49 percent more bytes than the original for more time than level 3 costs.
Open each file with Pillow, convert to RGB, then save to a BytesIO at each level with time.perf_counter() around the call and read .tell() for the byte count. Writing to memory rather than disk keeps filesystem caching out of the timings. Run the levels in the same order for every file and do the whole sweep in one process so the interpreter is warm throughout.
Two caveats belong on the record. Four images is a small sample and all four are photographic portraits at one resolution, so a gallery of flat colour or line art would compress very differently and the curve would bend somewhere else. And these timings are single threaded on one machine, so the absolute milliseconds will not match yours. The ratios are what transfer.