PNG has five filters, and the one the spec recommends costs this site 23%

PNG does not compress images. DEFLATE does. What PNG contributes is a step that happens first, and that step is where most of the file size is decided: before a single byte reaches the compressor, every row of the image is rewritten as a set of differences from its neighbours. The format calls this filtering, it offers five ways to do it, and it lets the encoder pick a different one for every row.

The names are in every PNG tutorial: None, Sub, Up, Average, Paeth. What is almost never in those tutorials is a number. Which one actually wins, on what kind of picture, and by how much?

We measured it on this site's own files. The answer for interface screenshots turned out to be the opposite of what the specification recommends.

A filter is not what most people think it is

The first thing to get right is that PNG filtering does not work on pixels. It works on bytes, and the distinction matters as soon as a pixel stops being one byte wide.

For a 24-bit RGB image, each pixel is three bytes. The filter's idea of "the pixel to the left" is therefore the byte three positions back, not the byte one position back. The specification calls that distance bpp, and getting it wrong is the classic way to write a PNG encoder that produces files twice the size they should be while still decoding correctly.

Each scanline is prefixed with one byte naming its filter, then every byte in that row is replaced by the difference between it and a prediction. With a the byte bpp positions to the left, b the byte directly above, and c the byte above-and-left:

0  None      out = raw
1  Sub       out = raw - a
2  Up        out = raw - b
3  Average   out = raw - floor((a + b) / 2)
4  Paeth     out = raw - PaethPredictor(a, b, c)

All arithmetic is modulo 256, which is why the output of a filter is still a byte and why a difference of -1 comes out as 255. The Paeth predictor picks whichever of a, b or c is closest to a + b - c, which is a cheap guess at which direction the image is changing in.

Note what all four predictors have in common: they read the raw neighbours, not the filtered ones. There is no dependency chain inside a row. That is a deliberate design choice and it is why a decoder can undo an entire scanline without working left to right.

Proving the implementation before trusting it

Measuring filters means writing an encoder, and an encoder that is subtly wrong produces subtly wrong numbers. So before any measurement was recorded, each of the five filters was used to write a complete, real PNG file — signature, IHDR, IDAT, IEND, CRCs and all — which was then handed back to a standard decoder and compared against the source bytes.

filter 0 (None   ) →   26001 byte PNG, decodes pixel-identical: True
filter 1 (Sub    ) →   33551 byte PNG, decodes pixel-identical: True
filter 2 (Up     ) →   36690 byte PNG, decodes pixel-identical: True
filter 3 (Average) →   44572 byte PNG, decodes pixel-identical: True
filter 4 (Paeth  ) →   37157 byte PNG, decodes pixel-identical: True

Five different byte streams, one identical image. That is the whole promise of lossless filtering, and it is also the check that makes the rest of this article worth reading.

The measurement

Four real files, each compressed five times, once per filter, with DEFLATE at level 9 and a 32 KB window. The sixth column is the adaptive strategy the PNG specification recommends to encoders: try all five on each row, keep the one whose bytes have the smallest total distance from zero.

file                    size        None      Sub       Up     Average    Paeth   adaptive
screenshot-home.png   1440x600    60,590   77,041   80,788   100,275   82,061    78,752
screenshot-tool.png   1440x640    61,850   79,229   80,760   101,341   81,477    78,376
icon-512.png           512x512   457,437  296,053  297,584   279,313  281,816   281,010
og-image (decoded)   1200x1200 1,092,036  860,912  786,644   909,108  781,057   774,036

Three things fall out of that table.

The spread is large. On the app icon, the worst filter produces a file 1.64 times the size of the best one. Same pixels, same compressor, same settings — the only variable is a one-byte decision taken 512 times.

There is no winner. Average wins on the icon, Paeth wins on the photographic card, and None — doing nothing at all — wins on both screenshots. Any article that tells you Paeth is the best filter is describing photographs and has not looked at anything else.

On the screenshots, the recommended heuristic loses badly. Adaptive gives 78,752 bytes where simply switching filtering off gives 60,590. The strategy the specification suggests, and that every mainstream encoder implements, produces a file 30% larger than the laziest possible option.

That is not a simulation artefact

A claim like that is only worth making if the adaptive column really is what encoders do, rather than what we think they do. It is checkable: the filter type of every row is stored in the file, one byte at the start of each decompressed scanline. Pull them out of the shipped PNG and count them.

screenshot-home.png, as published:
   filter types inside the file:  Up x333, Paeth x210, Sub x54, Average x3

our adaptive simulation, same image:
   adaptive chose:                Up x333, Paeth x210, Sub x54, Average x3

Row for row, the same decisions. The encoder that produced this site's press screenshots made exactly the choices the model predicts, and those choices cost 18,267 bytes on one 1440x600 image.

Rewriting the three press screenshots with filtering switched off, and checking each one decodes to identical pixels:

screenshot-home.png    78,914 →  60,647 B   (23% smaller)   pixel-identical
screenshot-tool.png    79,127 →  61,907 B   (21% smaller)   pixel-identical
screenshot-why.png    133,126 →  99,005 B   (25% smaller)   pixel-identical

No quality setting was touched, because PNG has none. These are the same images.

A small consistency check, since it is the kind of thing that catches an error: the raw filtered stream for the first file compressed to 60,590 bytes, and the finished PNG is 60,647. The 57-byte difference is the file signature plus the IHDR, IDAT and IEND chunk headers and CRCs. The numbers agree with the format.

Why doing nothing wins

The obvious explanation is that a screenshot is full of flat colour, and flat colour compresses well either way. That explanation is incomplete, because filtering a flat area with Up turns it into a run of zeros, which should compress better, not worse. Something else is going on, and it can be measured rather than guessed at.

DEFLATE compresses by pointing backwards: when it meets a sequence it has seen before, it emits a reference instead of the data. How far back it can point is limited by the sliding window. Shrink that window and any advantage that depends on long-distance references disappears.

window     None        Up       verdict
  512 B  121,954    98,310     Up wins by 19%
   2 KB  105,480    83,270     Up wins by 21%
   8 KB   66,669    81,923     None wins by 19%
  32 KB   60,590    80,788     None wins by 25%

The result flips between 2 KB and 8 KB. One scanline of this image is 1440 x 3 = 4,320 bytes, so the crossover is almost exactly the moment the window grows past a single row.

That locates the effect precisely. While the compressor can only see back a fraction of a row, prediction is all it has, and Up wins. Once it can reach the previous row and beyond, something stronger becomes available — and in a screenshot there is a great deal of it:

identical scanlines repeated elsewhere in the image: 225 of 600

More than a third of the rows in that screenshot are byte-for-byte copies of another row somewhere else in the file. Toolbars, table rows, repeated panels, the same glyphs rendered with the same anti-aliasing against the same background. Left unfiltered, those rows are literally identical byte sequences and DEFLATE collapses each one into a short reference.

Filtering destroys that. A row of pixels filtered with Up becomes a row of differences against whatever happened to be above it. Two visually identical rows in different parts of the image sit on different backgrounds, so their filtered forms differ, and the match the compressor would have found is gone. The filter spends a real gain on long-range repetition to buy a small gain on local gradients.

Photographs are the opposite case. In the decoded social card, almost no two rows are identical — a JPEG source also carries the encoder's own block noise, which guarantees it — so there is no long-range repetition to lose, and prediction is the only lever left. There, adaptive filtering wins, by 1%.

When this does not apply

This is a narrow result and it is easy to over-apply.

It does not apply to photographs. On continuous-tone images, filtering wins and the heuristic works. Turning filtering off on a photograph in our test made the file 40% larger.

It is about this class of image, not this file. The rule that falls out of the measurement is "large flat regions plus repeated interface elements", not "PNG screenshots". A screenshot of a photograph, or one with a gradient wallpaper behind it, behaves like a photograph.

It does not survive resizing. Scale a screenshot by anything other than a whole number and the resampler puts slightly different values into rows that used to be identical. The 225 repeated rows become 0, and with them the entire advantage.

It is not a substitute for choosing a smaller format. On the same three screenshots, lossless WebP beats the best PNG we could produce by a wider margin than this entire article is about:

screenshot-home    best PNG  60,647    lossless WebP  41,246   (31% smaller)
screenshot-tool    best PNG  61,907    lossless WebP  39,272   (36% smaller)
screenshot-why     best PNG  99,005    lossless WebP  58,162   (41% smaller)

Filter choice is worth knowing about when PNG is a requirement rather than a preference. When it is a preference, the bigger win is upstream of everything discussed here.

We did not test every encoder. The comparison is against the heuristic the specification recommends and the shipped files demonstrably used. Encoders that brute-force filter combinations rather than using the per-row heuristic — the slow optimisers — will find this saving themselves, which is a large part of why they exist.

Checking it yourself

You do not have to take any of this on trust, and you should not. The filter byte at the start of each scanline is in every PNG you own.

Decompress the IDAT data, then read one byte every width x bpp + 1 positions: that is the filter type of each row, a number from 0 to 4. If your screenshots are mostly 2s and 4s, the measurement above applies to them.

For a cruder but faster check, open a screenshot in any image editor and save it twice, once with compression tuned for speed and once for size, and compare. Encoders that change filter strategy between those settings will show a difference far larger than the compression level alone could explain.

What we changed

The three press screenshots on this site are now written without filtering. They are the same images — verified by decoding both versions and comparing every pixel — and they are 23%, 21% and 25% smaller than they were.

The broader point is not about PNG. It is that the recommended default in a thirty-year-old specification was written when a 32 KB window was a lot of memory and interface screenshots were not most of the images on the internet. The default is still reasonable. It is simply not the right answer for a kind of image the specification was not thinking about, and the only way to find that out was to measure it.

Sources

Tools mentioned

Advertisements

← All articles · corrections: editorial policy