Compressing a JPEG to an Exact File Size in Python
Binary search on the quality dial is the easy half. The interesting part is what happens when even the lowest quality overshoots — and one flag whose payoff runs in the opposite direction from what you would guess.
Government application portals, exam boards and visa systems all do the same thing: they demand an upload under an exact file size. Twenty kilobytes for a signature. Fifty for a passport photo. If your file is 21 KB the form rejects it, usually with no explanation.
Pillow has no API for this. You can ask for quality=75, but quality is not size. The same setting produced 1.2 MB for one of my test photos and 2.6 MB for another at identical dimensions. There is no lookup table, because the mapping depends entirely on the content of the image.
This is how I built it for Fix Shots, what I measured along the way, and the one result that surprised me. Full runnable code and the benchmark script are at the bottom.
Why JPEG, and only JPEG
You need a format whose file size can be steered continuously. That rules out PNG immediately — it is lossless, so the only lever is the compression effort, which moves the size by a few percent at most. You cannot take a 3 MB PNG to 20 KB by asking politely.
WebP and AVIF both have quality dials and everything below works on them unchanged. I ship JPEG because the forms that impose these limits almost universally demand JPG, but nothing here is JPEG-specific except the quality range.
Step 1: binary search on quality
Encoded size is monotonically non-decreasing in the quality parameter. That makes it a binary search: find the highest quality whose output still fits under the target. Highest quality that fits is, by definition, the best-looking file that clears the limit.
Monotonicity is an assumption worth checking rather than believing. Sweeping q20–q95 on my test images, size increased at every single step, with no violations. It is not a guarantee across all encoders and all content, so the search below keeps the best fitting result it has actually seen rather than trusting the final bound. A small non-monotonic wobble then costs you a quality point, not correctness.
def quality_search(img, target_bytes):
lo, hi = MIN_QUALITY, MAX_QUALITY
best, best_q = None, None
while lo <= hi:
mid = (lo + hi) // 2
data = encode(img, mid)
if len(data) <= target_bytes:
best, best_q = data, mid
lo = mid + 1
else:
hi = mid - 1
return best, best_q
Over a 76-point range that is seven encodes. Cheap.
Step 2: when the quality dial runs out
Here is the case that breaks naive implementations. Encode a 12.5 MP phone photo at quality 20 — the floor, below which faces stop being recognisable — and you are still nowhere near a 20 KB target. On my four test images that floor came in at 201 KB, 291 KB, 423 KB and 867 KB. No quality setting will save you. There are simply too many pixels.
So the loop is: encode at the minimum quality first. If that floor still overshoots, do not bother searching. Shed pixels and try again.
How many pixels? JPEG size tracks pixel count roughly linearly, so to get from floor bytes down to target bytes you scale each edge by sqrt(target / floor). That estimate is deliberately multiplied by 0.95 to undershoot slightly. The asymmetry matters: undershooting costs nothing, because step 3 hands the spare bytes back as quality. Overshooting costs a whole extra round of the loop.
step = math.sqrt(target_bytes / floor) * 0.95
scale = scale * max(0.2, min(0.85, step))
The clamp stops a wild estimate from collapsing the image to a smudge in one round, or from making so little progress that the loop runs out of rounds.
Step 3: step back up
Shrinking is a blunt instrument. You aim for 20 KB, land at 14 KB, and you have thrown away 6 KB of resolution you were entitled to.
So: if the result came in below 85 % of budget and we had shrunk to get there, estimate a larger scale from the same sqrt relationship, re-run the quality search, and keep the new result only if it is genuinely bigger. One extra round, and spare bytes end up as pixels instead of sitting unused.
One detail that is easy to get wrong: every resize is taken from the original image, never from an already-downscaled copy. Chaining Lanczos passes softens the result cumulatively, and the step-up round would otherwise be resampling something already blurred.
The flag, and the thing I had backwards
optimize=True makes libjpeg compute a Huffman table tailored to the image instead of using the standard one. It is usually described as a marginal win that costs roughly 1.6× the encode time, so the common advice is to apply it only to the final save.
I apply it to every encode instead, including all the throwaway trial encodes. Here is what it saves at a fixed quality and fixed resolution, across four 12.5 MP photographs from the same phone:
| Test image | q20 | q40 | q60 | q80 |
|---|---|---|---|---|
| Desk, indoor, flat wall | 33.1 % | 20.5 % | 14.0 % | 6.8 % |
| Sofa against plain wall | 23.9 % | 13.3 % | 8.0 % | 3.2 % |
| Printed page, scanned | 14.0 % | 7.7 % | 4.8 % | 2.8 % |
| Textile close-up, dense weave | 6.5 % | 2.6 % | 1.5 % | 1.2 % |
Two patterns, and the second one is the one I had backwards.
The saving is largest at low quality — which is convenient, because a tight byte target is exactly what forces you down there.
The saving is largest on the least detailed images. I had assumed the opposite: that a busy, high-entropy photo would have the most to gain from a bespoke Huffman table. It is the reverse, and the reason is straightforward once you see it. A custom Huffman table only pays off when the coefficient distribution is skewed enough that some symbols are much more common than others. The dense chenille weave in that last test image is close to noise — its q20 encode is already 867 KB — and a near-uniform symbol distribution leaves an optimal table almost nothing to exploit. The desk photo is mostly flat wall and dark screens, encodes to 201 KB at the same quality, and gives up a third of its bytes.
So the effect ranges from “barely worth mentioning” to “a third of the file” depending on what you point it at. It is never a regression, which is what makes the decision easy.
What the algorithm spends the saving on
Not on landing closer to the target. The binary search gets within one or two percent of budget with the flag off as well — that is the search’s job, not the flag’s. What the saving buys is resolution:
| Test image | Target | optimize off | optimize on | Pixel gain |
|---|---|---|---|---|
| Desk, indoor | 20 KB | 641×482 | 734×552 | +31 % |
| Desk, indoor | 100 KB | 1901×1431 | 2324×1750 | +50 % |
| Sofa, plain wall | 20 KB | 753×567 | 864×650 | +31 % |
| Sofa, plain wall | 300 KB | 3433×2585 | 4080×3072 | +41 % |
| Printed page | 100 KB | 1172×882 | 1251×942 | +14 % |
| Textile close-up | 100 KB | 1081×814 | 1118×842 | +7 % |
Across all twenty runs the pixel gain ranged from nothing to +50 %, with a median of +11 %. Three caveats on that spread, because the zeroes and the outliers both have explanations:
- At a 1000 KB target the gain was zero for every image. All four already fit at full resolution, so there was no resolution to win — the flag bought two or three quality points instead. The gain only materialises when the resolution constraint is actually binding.
- One row came out at +0.1 % pixels. The desk photo at 50 KB landed on essentially the same dimensions either way, but at q27 with the flag instead of q20 without it. Same trade, different currency.
- The time you pay is smallest exactly where the benefit is largest. The flag was slower end to end in all twenty runs, but the penalty ranged from +3 % to +149 % (median +25 %), and it tracks the pixel gain inversely (r = −0.60). Where the gain was zero the flag cost +123 % on average; where the gain exceeded 30 % it cost +21 %. The mechanism is visible in the two rows where the smaller encodes let the image fit at full resolution and the loop skipped a shrink round entirely — those cost +7 % and +3 %, against a median of +31 % elsewhere. A 1.6× per-encode cost does not translate into a 1.6× slower pipeline.
If you take one thing from this, take this: use optimize=True on every trial encode, not just the last one — because the trial encodes are what decide the resolution you end up shipping. Then measure on your own images, because how much it is worth varies by a factor of five.
Cost
Seven to eleven encodes per run across the twenty measured, and 156 ms to 491 ms end to end for a 12.5 MP input on a mid-range laptop. Tighter targets need more shrink rounds before the quality search can begin, so they sit at the upper end. Run-to-run variance on any single measurement is wide enough to reorder a close comparison, so the script below reports medians over repeats rather than one-shot timings.
If that is too slow for your use case, the first thing to cut is the search range. Most photographs at a tight target land between q20 and q40, so starting the binary search there and widening only on failure removes two or three encodes.
The whole thing
"""Compress a JPEG to at-or-under an exact byte target."""
import io
import math
from PIL import Image
MIN_QUALITY = 20
MAX_QUALITY = 95
MAX_SHRINK_ROUNDS = 6
MIN_EDGE_PX = 40
def encode(img, quality, optimize=True):
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality,
optimize=optimize, subsampling=2)
return buf.getvalue()
def scaled(img, scale):
if scale >= 0.999:
return img
return img.resize(
(max(1, int(img.width * scale)), max(1, int(img.height * scale))),
Image.LANCZOS,
)
def quality_search(img, target_bytes):
lo, hi = MIN_QUALITY, MAX_QUALITY
best, best_q = None, None
while lo <= hi:
mid = (lo + hi) // 2
data = encode(img, mid)
if len(data) <= target_bytes:
best, best_q = data, mid
lo = mid + 1
else:
hi = mid - 1
return best, best_q
def compress_to_target(img, target_bytes):
scale = 1.0
for _ in range(MAX_SHRINK_ROUNDS):
work = scaled(img, scale)
floor = len(encode(work, MIN_QUALITY))
if floor <= target_bytes:
data, quality = quality_search(work, target_bytes)
if data is None:
data, quality = encode(work, MIN_QUALITY), MIN_QUALITY
if scale < 1.0 and len(data) < target_bytes * 0.85:
up = min(1.0, scale * math.sqrt(target_bytes / max(1, len(data))) * 0.97)
if up > scale * 1.02:
bigger = scaled(img, up)
if len(encode(bigger, MIN_QUALITY)) <= target_bytes:
alt, alt_q = quality_search(bigger, target_bytes)
if alt is not None and len(alt) > len(data):
data, quality, work = alt, alt_q, bigger
return data, work.size, quality
step = math.sqrt(target_bytes / floor) * 0.95
scale = scale * max(0.2, min(0.85, step))
if scale * img.width < MIN_EDGE_PX or scale * img.height < MIN_EDGE_PX:
break
work = scaled(img, scale)
return encode(work, MIN_QUALITY), work.size, MIN_QUALITY
Reproducing the numbers
Save the above as exactsize.py alongside this, then point it at your own photographs. It prints both tables from this article directly, including the pixel-gain column, and a range and median across every run at the end.
"""
Reproduce the numbers in the article on your own photos.
python bench.py photo1.jpg photo2.jpg ...
Prints, per image:
1. bytes at each quality with optimize on vs off
2. what the full algorithm lands on at each target, both ways,
including the pixel gain that optimize=True actually buys
Timings are the median of REPEATS runs, because a single run is noisy
enough to invert the comparison by chance.
"""
import statistics
import sys
import time
from PIL import Image
import exactsize
from exactsize import compress_to_target
TARGETS_KB = (20, 50, 100, 300, 1000)
QUALITIES = (20, 30, 40, 50, 60, 70, 80, 90)
REPEATS = 3
_real_encode = exactsize.encode
def _patched(optimize_flag):
def enc(im, q, optimize=True):
return _real_encode(im, q, optimize=optimize_flag)
return enc
def _run(img, target, optimize_flag):
"""Median wall time over REPEATS, plus the result of the last run."""
exactsize.encode = _patched(optimize_flag)
times, out = [], None
for _ in range(REPEATS):
t0 = time.perf_counter()
out = compress_to_target(img, target)
times.append((time.perf_counter() - t0) * 1000)
exactsize.encode = _real_encode
data, size, quality = out
return len(data), size, quality, statistics.median(times)
def bench(path, gains):
img = Image.open(path).convert("RGB")
print(f"\n=== {path} {img.width}x{img.height} "
f"{img.width * img.height / 1e6:.2f} MP ===")
print("\nSame image, same quality - what optimize=True saves:")
print(" q | off bytes | on bytes | saving")
for q in QUALITIES:
a = len(_real_encode(img, q, optimize=False))
b = len(_real_encode(img, q, optimize=True))
print(f" {q:3} | {a:12} | {b:12} | {(a - b) / a:+6.1%}")
print("\nFull algorithm - what the saving is spent on:")
print(" target | optimize=False | optimize=True | pixel gain")
for kb in TARGETS_KB:
t = kb * 1024
n0, s0, q0, d0 = _run(img, t, False)
n1, s1, q1, d1 = _run(img, t, True)
gain = (s1[0] * s1[1] - s0[0] * s0[1]) / (s0[0] * s0[1])
gains.append(gain)
print(f" {kb:5} K | {n0 / t:5.1%} {s0[0]:>4}x{s0[1]:<4} q{q0:<2} {d0:4.0f}ms "
f"| {n1 / t:5.1%} {s1[0]:>4}x{s1[1]:<4} q{q1:<2} {d1:4.0f}ms "
f"| {gain:+7.1%}")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
gains = []
for p in sys.argv[1:]:
bench(p, gains)
if gains:
print(f"\nPixel gain over {len(gains)} runs: "
f"{min(gains):+.1%} to {max(gains):+.1%}, "
f"median {statistics.median(gains):+.1%}")
A word on the corpus, since it is the obvious weakness. Four images, all 4080×3072 camera originals from a single phone, unedited, measured on Pillow 12.1.1 against libjpeg-turbo. One sensor, one JPEG pipeline, one encoder build. The direction of the result should hold anywhere; the magnitudes should not be assumed to. Treat the fivefold spread between my own four images as the point rather than as noise.
What I would still change
- Chroma subsampling is fixed at 4:2:0. That is the right default for photographs and the wrong one for images containing coloured text, where it smears edges. It should be content-dependent, or at least an argument.
- Quality 20 as a floor is a judgement, not a measurement. It is where faces stopped being recognisable to me. A perceptual metric such as SSIM or butteraugli would be a defensible way to set it instead of a hunch.
- The step-up round runs at most once. Iterating it to convergence would recover a little more resolution at the cost of more encodes. I have not measured whether the gain is visible.
- No progressive JPEG. Progressive encoding often produces smaller files still, and every browser supports it. I have not tested whether it upsets the strict validators these upload portals run.
- The entropy finding deserves to be exploited, not just noted. If the gain from
optimize=Trueis predictable from a cheap detail estimate, the flag could be skipped on high-entropy images to halve their encode cost for almost no loss. I have four data points, which is not enough to build on.
The working version of this is the Compress to Exact Size tool — upload a photo, name a byte target, get a file under it. Free, no account, nothing stored.