Back to News & Insights
Web Development September 23, 2026 · 5 min read

PNG admits when it is damaged and JPEG does not

One bit flipped, six image formats, 400 trials each. Every corrupted PNG was refused by Pillow and libpng, yet Chromium painted half of each one correctly. JPEG rendered complete images in which 96% of pixels were the wrong colour.

PNG admits when it is damaged and JPEG does not

A cosmic ray, a dying SSD sector, a truncated upload, a bad cable. Somewhere in your storage, one bit in one image file flips from a 0 to a 1. What happens to the picture?

The honest answer is that it depends entirely on the format, and the differences are much stranger than "some are more robust than others". I encoded the same 512×512 image as PNG, JPEG, GIF, WebP, AVIF and BMP, flipped exactly one bit in each file, and decoded the result. Four hundred times per format.

Two formats fail in ways that are almost opposites, and the one that looks healthier is the one I would trust least.

Start with the result that surprised me most. Out of 400 single-bit flips in a PNG file:

Every single one. Pillow would not decode any of them. I assumed this was a quirk of one library being strict, so I ran the same corrupted files through OpenCV, which uses libpng underneath, and got the same answer: 120 out of 120 refused, with libpng naming the reason.

That is the format working exactly as designed. PNG stores a CRC32 for every chunk, so a flipped bit is detected rather than absorbed, and the image data is one DEFLATE stream, so a flip that gets past the checksum desynchronises the decompressor and everything after it is garbage.

Then I served those same rejected files to Chromium, and it rendered 10 out of 10.

Not fully, though, and this is the interesting part. I drew each one to a canvas and compared it with the original pixel by pixel. The browser painted a median of 48.6% of the image — and every pixel it painted matched the original. It decodes as far as the damage, stops there, and leaves the rest blank.

So a bit-rotted PNG shows most users a half-drawn picture and hands your backend an exception. Your thumbnailer, your CI, your image pipeline all reject the file that your visitors can partly see.

| format | browser refused | median % of image painted | median % of pixels matching the original | |---|---|---|---| | PNG | 0 of 10 | 48.6% | 48.6% | | JPEG | 0 of 10 | 100% | 3.6% | | WebP | 0 of 10 | 100% | 2.3% | | GIF | 0 of 10 | 100% | 11.8% | | AVIF | 1 of 10 | 100% | 2.3% | | BMP | 0 of 10 | 100% | 100% |

Read the last column carefully. JPEG produced a full, complete, confident image in which 96% of the pixels are not the colour they should be.

Look back at the screenshot and you will see why that is easy to miss: the corrupted JPEG looks fine. The damage lands in a DC coefficient, the decoder happily carries the error forward through the rest of the scan, and what comes out is a picture with a slightly wrong cast across the whole frame. Nothing about it says "damaged". It just is.

PNG gives you half a picture and tells you the truth about it. JPEG gives you a whole picture and quietly lies.

BMP sat at 100% painted and 100% matching, and that is not an error in the table. It stores raw pixels with no compression, no checksum and no entropy coding, so one flipped bit changes one colour channel of one pixel out of 262,144. In 400 trials the median damage was too small to register.

The format with no error detection at all is the one that degrades most gracefully, because there is no shared state for the error to propagate through. Compression is what turns one bad bit into a ruined file.

I wrapped the experiment in a small app so the damage is visible rather than described, and put it on DigitalOcean's App Platform straight from a public git repo — no registry, no CI, just a spec and a clone URL. It built and went live in about two minutes.

The Pillow build in that Python buildpack has no WebP encoder. My first fix made it worse, because I guarded the format list with features.check("webp") — which reports decode support and returns True on a build that cannot write the format at all. The correct test is whether the format is in Image.SAVE, the registry Pillow actually consults when saving. With that, the app drops WebP from the list and serves the four it can encode.

That is a useful thing to have learned by accident: the experiment ran perfectly on my own machine, and it took a different Python build, on someone else's infrastructure, to reveal that my capability check had been asking the wrong question the whole time.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation