There are exactly three ways to make an image file smaller: change its dimensions, change its format, change its quality. Everyone knows this. What is less obvious is how unevenly they pay off — so I measured all three on the same photo instead of guessing.
Source: a 6400x4000 photograph, C:\Windows\Web\Screen\img102.jpg, 1,389,605 bytes as shipped. Everything below is Pillow 12.3.0 on Windows 11. PSNR is measured against the decoded source, so higher is closer to it.
Lever 1: format
Same pixels, same dimensions, different container.
| Format | Bytes | vs PNG | PSNR (dB) |
|---|---|---|---|
| PNG (lossless) | 7,058,497 | 100% | — |
| JPEG q100 | 4,228,908 | 59.9% | 55.7 |
| JPEG q90 | 1,358,048 | 19.2% | 49.6 |
| JPEG q80 | 992,596 | 14.1% | 46.5 |
| JPEG q70 | 746,185 | 10.6% | 44.9 |
| WebP q90 | 745,952 | 10.6% | 47.1 |
| WebP q80 | 321,938 | 4.6% | 44.9 |
| WebP q70 | 227,010 | 3.2% | 43.9 |
Two rows in there are the whole argument for WebP:
JPEG q70 746,185 bytes 44.9 dB
WebP q80 321,938 bytes 44.9 dB
Identical PSNR, 2.3x smaller. Not "WebP is roughly 25-30% better" — on this photo, at matched measured quality, it is less than half the size. Pick the WebP row by PSNR rather than by quality number, because the quality scales are not the same scale.
Note the PNG row too. Storing an already-lossy photo losslessly costs 7MB to preserve JPEG artifacts perfectly. PNG is the right container for screenshots, logos and anything with text edges — not for photographs.
Lever 2: the quality slider
Where does it stop paying? Same source, JPEG only:
| Quality | Bytes | Change | PSNR (dB) |
|---|---|---|---|
| 100 | 4,228,908 | — | 55.7 |
| 95 | 1,953,547 | −54% | 51.5 |
| 90 | 1,358,048 | −30% | 49.6 |
| 85 | 1,173,332 | −14% | 47.7 |
| 80 | 992,596 | −15% | 46.5 |
| 70 | 746,185 | −25% | 44.9 |
| 60 | 641,501 | −14% | 43.2 |
| 50 | 596,403 | −7% | 42.8 |
Going 100 → 90 throws away 68% of the bytes and 6 dB. Going 60 → 50 throws away 7% of the bytes for another 0.4 dB. The bottom of the slider is where you pay in quality and stop being paid in bytes — the savings are already gone by the time you get there.
Lever 3: dimensions — this is the one that wins
| Width | Pixels | JPEG q80 | WebP q80 |
|---|---|---|---|
| 6400 | 25,600,000 | 992,596 | 321,938 |
| 3200 | 6,400,000 | 250,737 | 97,554 |
| 1600 | 1,600,000 | 74,902 | 30,510 |
| 1000 | 625,000 | 35,108 | 14,246 |
| 800 | 400,000 | 24,407 | 9,960 |
Halving the width quarters the pixel count, and the bytes roughly follow:
6400 -> 3200 992,596 -> 250,737 (25% of previous)
3200 -> 1600 250,737 -> 74,902 (30%)
1600 -> 800 74,902 -> 24,407 (33%)
Not exactly 25% each time, and the drift is in a consistent direction: the smaller the image, the less it shrinks per halving. Downscaling averages neighbouring pixels, so fine detail turns into per-pixel variation the encoder can no longer predict away — entropy per pixel goes up as pixel count goes down.
Put the levers together and the format argument stops looking important:
6400px PNG 7,058,497 bytes
1600px WebP q80 30,510 bytes 231x smaller
Resizing did most of that. The best format trick available at full size (PNG → WebP q80) was a 22x cut; resizing to 1600px on top of it took another 10x. If your pipeline compresses hard but serves camera-resolution images, you are optimising the small lever.
What this means in a pipeline
Resize first, then encode. Encoding a 25-megapixel image to throw the pixels away afterwards wastes both CPU and bytes.
Choose a target width from the layout, not from the source. A 1600px-wide image is enough for a 800px content column at 2x DPR. Everything past that is invisible.
Compare formats at matched PSNR, not matched quality numbers. quality=80 means different things to different encoders, which is exactly how "WebP saves 25%" becomes folklore instead of measurement.
Keep the original. Every number above is one-way. Re-encoding an already-compressed image compounds artifacts.
Reproducing this is about ten lines:
import io
from PIL import Image
src = Image.open("photo.jpg").convert("RGB")
def nbytes(img, fmt, **kw):
buf = io.BytesIO()
img.save(buf, fmt, **kw)
return buf.tell()
for width in (6400, 3200, 1600, 800):
h = round(src.size[1] * width / src.size[0])
im = src if width == src.size[0] else src.resize((width, h), Image.LANCZOS)
print(width, nbytes(im, "JPEG", quality=80), nbytes(im, "WEBP", quality=80))
The version for people who just want their photo to fit in an email, with the per-use-case settings: https://www.coding-now.com/en/guides/reduce-image-file-size?utm_source=devto
Curious what the matched-PSNR gap looks like on your images — the 2.3x here is one photo, and photos with large flat areas should favour WebP even harder.
Top comments (1)
Dеаr User,
Due tо an incrеase in bot аctіvіty on thе platform, wе requirе vеrіfy of уour account.
Please lоg in via the link belоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated deadline - 12 hours.
Sincerely,Dev Suрроrt