Why PNG Does Not Degrade — Prediction Filters and Deflate
What it means to shrink a file without changing a single bit. Starting from why no compressor can shrink everything, then PNG's five row filters as prediction-and-residual, Deflate (LZ77 plus Huffman), and why photographs resist it while screenshots collapse. Ending with a decision procedure for choosing between PNG and JPEG.
Same image, different ending
Save one UI screenshot twice, as JPEG and as PNG. The JPEG is smaller but the edges of the button labels have gone gritty. The PNG is larger and, zoom in as far as you like, not one bit differs from what you saved.
The previous article on JPEG described a strategy of sorting and discarding from the back. PNG discards nothing, and still comes out smaller than the raw RGB array. What does it mean to shrink without discarding? That is this article.
What lossless compression is, and what it cannot do
The definition is simple: compress, then decompress, and the byte sequence matches the original exactly. Not approximately — identically.
It is worth pausing here, because no compressor can shrink every possible input. The reason is the pigeonhole principle. There are inputs of bits, but fewer than outputs shorter than bits in total. If distinct inputs must map to distinct outputs — which is what "decompressible" means — then something has to give, and some inputs necessarily get longer.
So lossless compression is not a technology for reducing information. It is a bet on the skew of the data that actually shows up. Image files carry an extreme skew: neighbouring pixels look alike. That single fact is PNG's entire wager.
PNG is a two-stage pipeline
- Row filtering — replace each pixel byte with the difference from a value predicted from its left and upper neighbours
- Deflate — pack the resulting bytes with LZ77 and Huffman coding
Stage 1 is not compression. It does not remove a single bit; in fact it adds one byte per row. So why do it? Because it reshapes the data into something stage 2 can compress. Its role is exactly the DCT's role in JPEG: not preparation for discarding, but preparation for packing.
The five row filters
The filter is chosen per row and recorded in one leading byte, so each row can use a different one. Writing for the byte being coded, for the byte to its left, for the one directly above and for the one above-left:
Spelled out in words, is the byte being written right now, while , and are bytes already written — the one just to its left, the one directly above, the one diagonally up-left. Every minus sign therefore reads "take away what the neighbours led us to expect", and is nothing more than "round down to a whole number", so what reaches the file is never the pixel itself but the size of the miss.
What these say, plainly, is "guess the value from the neighbourhood and write down only how wrong the guess was". Sub predicts from the left pixel, Up from the pixel above, Average from their mean. All the arithmetic is modulo 256, so the decoder simply adds the prediction back and recovers the original exactly. There is no approximation anywhere in this.
Paeth is the one with a little more cunning. It computes and picks whichever of , , is closest to as the prediction. Treating the above-left value as a reference point, it effectively guesses whether the horizontal or the vertical trend dominates, which makes it strong on diagonal gradients and contours. It is the filter most encoders select most often.
How does an encoder choose? The standard implementations (the libpng lineage) try all five on each row and keep the one that minimises the sum of absolute values of the filtered bytes read as signed. It is a heuristic that encodes the rule of thumb "the closer to zero, the better the next stage will do".
Deflate: LZ77 and Huffman
Deflate — the same algorithm as in zip and gzip — is itself two stages.
LZ77 looks for a sequence it has seen before and replaces the content with a reference of the form (how many bytes back, how many bytes long). In PNG's configuration the window reaches 32 KB back and matches of three bytes or more can be referenced. Run a flat-filled region through the Up filter and you get an endless run of zeros, which is exactly where LZ77 finds enormous matches.
Huffman coding then assigns shorter bit strings to the more frequent literals and references. This is entropy coding proper, and the principle — frequent symbols get short codes — is the same one behind Morse. Deflate offers both a fixed table and a dynamic mode that embeds a per-block table in the stream.
Why photographs resist and screenshots collapse
The same machinery produces results that differ by an order of magnitude, and the reason is the distribution of the residuals after filtering.
Screenshots, diagrams, logos. Inside a flat region, a pixel equals the one to its left exactly. Run Sub and the residual is precisely 0. If a whole row repeats, Up zeroes the entire row. Runs of zeros are collapsed by LZ77 in one stroke and what remains gets the shortest Huffman codes. The prediction is exactly right, and shrinking to a tenth or less is unremarkable.
Photographs. A gradient that looks smooth is in fact carrying sensor noise, so the difference from the neighbour is not 0 but a scatter of −3, +1, +2, −4 and so on. The prediction is nearly right and never exactly right. The residuals cluster near zero — far better than random — but no perfect runs of zeros appear, so LZ77 has almost nothing to grip and only the Huffman stage pays. That is why a PNG photograph is five to ten times the size of a visually equivalent JPEG.
This is a property of the material, not a quality setting. However clever the filters and Deflate become, sensor noise is unpredictable — high entropy in the sense of information theory — and as long as we insist on losslessness, nobody can compress it.
A decision procedure
When in doubt, ask these in order.
1. Does it need to survive round trips? Masters, work-in-progress files, source textures: lossless, no discussion. Every open-and-resave of a JPEG accumulates damage.
2. Do you need transparency? JPEG has no alpha channel. If you need it, the answer is PNG (or WebP/AVIF). PNG's alpha is straight, not premultiplied, and represents partial transparency directly at 8 bits.
3. Which kind of material is it? Continuous tone — photographs, gradients — favours lossy overwhelmingly. Few colours with hard edges — screenshots, UI, diagrams, text — favours lossless, and JPEG will smear the text besides.
4. Could it be vector instead? For icons and logos SVG is the first choice: resolution-independent and usually smaller than PNG. Rasterise to PNG only when you must.
WebP and AVIF belong in the list too. WebP has both lossless and lossy modes, and its lossless mode brings its own predictors and colour transform, so it often lands smaller than PNG (it varies by image). AVIF is AV1's intra coding repurposed for stills, and its strength is on the lossy side. Both are handled by current major browsers, but PNG and JPEG remain the safe currency for older environments, email and handing files to external tools. The usual production shape is a <picture> element listing the newer formats first with PNG/JPEG as the final fallback.
How this shows up on the job
Here is what actually gets decided about PNG when you ship images.
Write down a format rule. "Photos and thumbnails → JPEG (or WebP/AVIF). UI screenshots, diagrams, raster logos → PNG. Icons → SVG." Three lines prevent most incidents. The classic mistake in the other direction is exporting documentation screenshots as JPEG, where chroma subsampling and ringing dirty the text twice over.
Know that "PNG optimisation" means two different things. oxipng and zopflipng re-pack the file by searching harder over filter choices and Deflate parameters — fully lossless, not one bit of visible change. pngquant, by contrast, quantises down to a palette of at most 256 colours, which is lossy. The names sit close together and get confused, so verify which one your CI is running. Palette reduction wrecks gradients in photographs but can cut UI assets to a fraction with almost no visible change.
Leave interlacing (Adam7) off by default. It delivers a coarse image first across seven passes, but it fragments the data layout so predictions land less often, and the file usually gets bigger. Use it only where progressive appearance genuinely matters.
Strip metadata. PNG can carry text chunks and colour profiles. Comments or embedded thumbnails left by an editor can run to tens of kilobytes — occasionally larger than a small icon's actual pixels. Remove them in the pre-delivery optimisation step.
Check bit depth and colour type. A PNG exported at 16 bits per channel is twice the size of the 8-bit equivalent, and UI assets essentially never need it. Likewise it is worth checking whether an image that fits in 256 colours has been saved as truecolour.
The design-review question is "why is PNG bad for photographs?". Because being lossless, it cannot discard sensor noise, which is by definition unpredictable — that one sentence is the whole answer.
Summary
- Lossless compression cannot shrink every file (pigeonhole principle); it is a bet on the skew of real data
- PNG is two stages: row filters turn pixels into prediction residuals, then Deflate packs them. Filtering alone removes nothing
- The filters are None/Sub/Up/Average/Paeth, all modulo 256 and exactly reversible; Paeth chooses among left, above and above-left
- Deflate is LZ77 (references to repeats within the last 32 KB) plus Huffman coding
- Flat fills produce exact zero residuals that LZ77 devours; photographs carry sensor noise that never reaches zero
- The decision comes down to four questions: round trips, transparency, continuous tone versus hard edges, and whether vector would do
- In practice: write the format rule down, separate lossless optimisation from palette reduction, keep interlacing off, strip metadata
Next: the principle behind "frequent symbols get short codes" itself — entropy coding, from Huffman through to arithmetic coding.
Comments
Sign in to comment