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 imageq20q40q60q80
Desk, indoor, flat wall33.1 %20.5 %14.0 %6.8 %
Sofa against plain wall23.9 %13.3 %8.0 %3.2 %
Printed page, scanned14.0 %7.7 %4.8 %2.8 %
Textile close-up, dense weave6.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 imageTargetoptimize offoptimize onPixel gain
Desk, indoor20 KB641×482734×552+31 %
Desk, indoor100 KB1901×14312324×1750+50 %
Sofa, plain wall20 KB753×567864×650+31 %
Sofa, plain wall300 KB3433×25854080×3072+41 %
Printed page100 KB1172×8821251×942+14 %
Textile close-up100 KB1081×8141118×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:

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


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.