Sitemap
Sahadat Husain

Sahadat Husain

Founder of Blinkimg (blinkimg.com), a free image compressor and converter. Measured guides on JPG, PNG, WebP, AVIF, HEIC, photo sizes and upload limits.

Compress an Image to a Specific File Size in Python and Node.js

Tested Pillow and sharp functions that never go over a KB cap, the bugs in the answers that rank, and when one API call is simpler.

16 min read4 days ago

--

Press enter or click to view image in full size
A wooden boat on a still lake at sunset beside the words Compress an image to a specific file size in Python and Node.js, with three labels reading Original 9,804.3 KB, Pillow 99.8 KB and sharp 97.3 KB
One 9.8 MB test photo held under 100 KB by the two functions in this story, measured on my laptop.

To compress an image to a specific file size in Python or Node.js, encode it in memory, count the bytes, and binary-search the quality from 5 to 95. The search takes 7 encodes at most. If even quality 5 is too big, shrink the pixels and search again. No quality number maps to a size, so the search is the only reliable way to hit a cap. On my laptop, sharp held our 9.8 MB test photo under 100 KB at 97,325 bytes, every pixel kept, in 2.59 seconds. Below are tested Pillow and sharp functions, the bugs in the answers that rank today, and the one-call API route.

This story was written with the assistance of an AI writing program. Every figure comes from a file measured with Blinkimg’s engine or from the source linked beside it.

Why can’t I just pick a quality for 100 KB?

One quality number gives a different size for every picture and in every library. At quality 75, sharp wrote 66.1 KB from our photo-like 800 × 600 test image and 542.3 KB from our 9.8 MB test photo. Pillow wrote 87.5 KB and 704.7 KB from the same two files. The test photo is a 4964 × 2782 boat at sunset, saved at JPEG quality 100.

Press enter or click to view image in full size
A bar chart of JPEG quality 75 on two files: a photo-like 800 by 600 test image is 66.1 KB in sharp and 87.5 KB in Pillow, and a 4964 by 2782 test photo is 542.3 KB in sharp and 704.7 KB in Pillow
One setting, four sizes. sharp 0.35.4 with MozJPEG and Pillow 12.3.0, measured on my laptop; the sharp figures match the Blinkimg engine’s.

What does hold is the order. On both files and in both libraries, every step up from quality 5 to 95 made a larger file, and a binary search needs nothing more. The maintainer of sharp put the premise plainly in sharp issue 2916: “the only way to know the output file size is to encode the image”.

How does a binary search find the right quality?

The search tries the middle quality, keeps the file if it fits, and halves the range each time. Quality 5 to 95 is 91 settings, so the search needs 7 encodes at most. Six rules keep the search safe:

  1. Decode once, upright. Apply the EXIF orientation and put transparent pixels on white before anything else.
  2. Probe in memory, from the original pixels. Never re-encode your own output, because the losses stack.
  3. Keep the largest file that fits. The answer is the highest quality at or under the cap.
  4. Return the original if it already fits and carries no EXIF block. Re-encoding a file that fits only costs quality.
  5. Shrink pixels only when quality runs out. Probe quality 45, multiply the width by the square root of cap ÷ bytes, times 0.9, and stop at 64 pixels.
  6. Never overwrite the input, and fail in words. Write a new file, or raise an error that names the cap.
Press enter or click to view image in full size
Six numbered steps of the size search: decode once upright, probe in memory from the original pixels, binary-search quality from 5 to 95, shrink the pixels when no quality fits, spend what is left, and write a new file or fail in words, with the sharp run under 100 KB trying quality 50, 27, 15, 9, 12, 13 and 14 and keeping 13 at 97,325 bytes
The search both functions run. The probe order at the foot is from the sharp run under 100 KB.

How do I compress an image to a specific file size in Python?

With Pillow, save each probe to a BytesIO buffer and count its bytes. Here is the whole script, tested with Pillow 12.3.0 on Python 3.13:

# fit_under.py: the largest JPEG at or under max_bytes (Pillow 12.3.0)
import io
import math
import sys
import time
from pathlib import Path
from PIL import Image, ImageOps
def encode(img, quality, fmt):
buf = io.BytesIO() # in memory: a probe never touches the disk
img.save(buf, fmt, quality=quality, optimize=True,
icc_profile=img.info.get("icc_profile"))
return buf.getvalue()
def best_quality(img, max_bytes, fmt, lo=5, best=(None, None)):
"""Binary search: the highest quality whose bytes fit, else `best`."""
hi = 95
while lo <= hi:
q = (lo + hi) // 2
data = encode(img, q, fmt)
if len(data) <= max_bytes:
best, lo = (data, q), q + 1
else:
hi = q - 1
return best
def fit_under(path, max_bytes, fmt="JPEG"):
raw = Path(path).read_bytes()
with Image.open(io.BytesIO(raw)) as im:
if len(raw) <= max_bytes and im.format == fmt and not im.getexif():
return raw, None, im.size # already fits, nothing to strip
img = ImageOps.exif_transpose(im) # turn the pixels upright, drop the tag
if fmt == "JPEG" and img.mode != "RGB":
rgba = img.convert("RGBA")
img = Image.new("RGB", img.size, "white") # transparency turns white, not black
img.paste(rgba, mask=rgba.getchannel("A"))
data, q = best_quality(img, max_bytes, fmt) # 7 encodes at most
small = img
min_width = math.ceil(64 * img.width / max(img.size)) # longest side 64 px or more
while data is None: # even quality 5 is too big: fewer pixels
probe = encode(small, 45, fmt)
if len(probe) <= max_bytes: # quality 45 fits: spend the rest on quality
data, q = best_quality(small, max_bytes, fmt, lo=46, best=(probe, 45))
elif small.width <= min_width:
raise ValueError(f"cannot fit {path} under {max_bytes} bytes as {fmt}")
else:
shrink = math.sqrt(max_bytes / len(probe)) * 0.9
width = max(min_width, math.floor(small.width * shrink))
height = max(1, round(img.height * width / img.width))
small = img.resize((width, height), Image.Resampling.LANCZOS) # from the original
return data, q, small.size
if __name__ == "__main__":
src, kb = Path(sys.argv[1]), int(sys.argv[2])
start = time.perf_counter()
data, q, (w, h) = fit_under(src, kb * 1000)
seconds = time.perf_counter() - start
dst = src.with_name(f"{src.stem}-{kb}kb.jpg")
with open(dst, "xb") as f: # "x" refuses to overwrite any file, the input included
f.write(data)
print(f"{dst.name}: {len(data):,} bytes, quality {q}, {w} x {h}, {seconds:.2f} s")

To compress an image to 100 KB in Python, run python3 fit_under.py photo.jpg 100. On my laptop, an Apple M1 Pro, the three caps printed:

photo-100kb.jpg: 99,800 bytes, quality 54, 1882 x 1055, 0.73 s
photo-50kb.jpg: 49,630 bytes, quality 51, 1237 x 693, 0.67 s
photo-5kb.jpg: 4,977 bytes, quality 52, 242 x 136, 0.70 s

Pillow shrank the picture for all three caps, because its quality 5 still weighed 102,187 bytes at full size. Three lines carry the weight:

  • ImageOps.exif_transpose turns a phone photo upright before the orientation tag is lost.
  • optimize=True cut quality 5 from 236,763 to 102,187 bytes on the test photo. Keep it on.
  • icc_profile keeps the colour profile, which Pillow otherwise drops without converting the colours.

How do I compress an image to a specific file size in Node.js with sharp?

With sharp, decode once to raw pixels, then encode every probe from them. The function switches MozJPEG on, as our engine does. Tested with sharp 0.35.4 on Node.js 24:

// fit-under.mjs: the largest JPEG at or under maxBytes (sharp 0.35.4)
import { readFile, writeFile } from 'node:fs/promises';
import sharp from 'sharp';
function encode({ data, info }, quality, width) {
const { height, channels } = info;
let img = sharp(data, { raw: { width: info.width, height, channels } });
if (width < info.width) img = img.resize({ width });
return img.jpeg({ quality, mozjpeg: true }).toBuffer({ resolveWithObject: true });
}
// Binary search: the highest quality whose bytes fit, else `best`.
async function bestQuality(pixels, maxBytes, width, lo = 5, best = null) {
let hi = 95;
while (lo <= hi) {
const quality = Math.floor((lo + hi) / 2);
const out = await encode(pixels, quality, width);
if (out.data.length <= maxBytes) [best, lo] = [{ ...out, quality }, quality + 1];
else hi = quality - 1;
}
return best;
}
async function fitUnder(input, maxBytes) {
const meta = await sharp(input).metadata();
if (input.length <= maxBytes && meta.format === 'jpeg' && !meta.exif) {
return { data: input, quality: null, info: meta }; // already fits, nothing to strip
}
// Decode once: upright (the EXIF turn applied), transparency on white.
const pixels = await sharp(input, { failOn: 'warning' })
.autoOrient()
.flatten({ background: '#ffffff' })
.raw()
.toBuffer({ resolveWithObject: true });
const { width: full, height } = pixels.info;
let best = await bestQuality(pixels, maxBytes, full); // 7 encodes at most
const minWidth = Math.ceil((64 * full) / Math.max(full, height)); // longest side 64 px or more
let width = full;
while (!best) { // even quality 5 is too big: fewer pixels
const probe = await encode(pixels, 45, width);
if (probe.data.length <= maxBytes) { // quality 45 fits: spend the rest on quality
best = await bestQuality(pixels, maxBytes, width, 46, { ...probe, quality: 45 });
} else if (width <= minWidth) {
throw new Error(`cannot fit this image under ${maxBytes} bytes as JPEG`);
} else {
const shrink = Math.sqrt(maxBytes / probe.data.length) * 0.9;
width = Math.max(minWidth, Math.floor(width * shrink));
}
}
return best;
}
const [src, kb] = process.argv.slice(2);
const start = performance.now();
const { data, quality, info } = await fitUnder(await readFile(src), kb * 1000);
const seconds = ((performance.now() - start) / 1000).toFixed(2);
const dst = src.replace(/\.\w+$/, `-${kb}kb.jpg`);
await writeFile(dst, data, { flag: 'wx' }); // "wx" refuses to overwrite any file, the input included
console.log(`${dst}: ${data.length.toLocaleString('en-US')} bytes, quality ${quality}, ` +
`${info.width} x ${info.height}, ${seconds} s`);

node fit-under.mjs photo.jpg 100, then 50 and 5, printed:

photo-100kb.jpg: 97,325 bytes, quality 13, 4964 x 2782, 2.59 s
photo-50kb.jpg: 49,408 bytes, quality 51, 1477 x 828, 3.01 s
photo-5kb.jpg: 4,999 bytes, quality 54, 298 x 167, 2.72 s

The same 100 KB cap kept every pixel in sharp and pushed Pillow down to 1882 × 1055. sharp’s prebuilt binaries encode with MozJPEG, which wrote quality 5 at 56,563 bytes where Pillow’s libjpeg-turbo wrote 102,187. autoOrient() needs sharp 0.34 or later. On older versions, rotate() with no angle does the same.

What if even the lowest quality is too big?

Then the picture needs fewer pixels, and both functions shrink them before they give up. Under 50 KB, quality 5 was too big at full size in both libraries: sharp’s came to 56,563 bytes. The second phase probes quality 45 at smaller widths until one fits. Then a second search spends the rest of the budget on quality.

Press enter or click to view image in full size
A bar chart of the pixels each library kept of a 4964 by 2782 photo: under 100 KB, Pillow 1882 by 1055 at quality 54 and sharp the full 4964 by 2782 at quality 13; under 50 KB, Pillow 1237 by 693 and sharp 1477 by 828, both at quality 51; under 5 KB, Pillow 242 by 136 at quality 52 and sharp 298 by 167 at quality 54
Every file landed at or under its cap. Pillow 12.3.0 and sharp 0.35.4 on an Apple M1 Pro; the bytes are in the list under the chart.
  • Under 100 KB: Pillow 99,800 bytes at 1882 × 1055, quality 54. sharp 97,325 bytes at 4964 × 2782, quality 13.
  • Under 50 KB: Pillow 49,630 bytes at 1237 × 693, quality 51. sharp 49,408 bytes at 1477 × 828, quality 51.
  • Under 5 KB: Pillow 4,977 bytes at 242 × 136, quality 52. sharp 4,999 bytes at 298 × 167, quality 54.

That second search is where your own code can beat a general one. Our engine stops at the first width that fits, so under the same 50 KB cap it returns 39.7 KB at 1371 × 768. The sharp function filled the cap to 49,408 bytes at 1477 × 828.

At very small caps, the fixed parts of a file start to count. Pillow keeps the 456-byte colour profile, so its smallest file here, 64 × 36 pixels, still weighed 1,231 bytes. Ask fit_under for 1,000 bytes and it stops with cannot fit photo.jpg under 1000 bytes as JPEG, and writes nothing. sharp drops the profile and reached 979 bytes at 82 × 46.

Why do the answers that rank get this wrong?

Most top results for this search never target a size: they fix the quality at 10, 60, 70, 75 or 100. The code that does try, and the answers people copy, break in five ways:

  1. The loop deletes your file. The top Google result, a 2018 Stack Overflow thread, lowers quality in steps of 5 and re-saves its own output on each pass, so the losses stack. When quality reaches 0, the loop calls os.remove() on the only copy.
  2. Photos turn sideways. Pillow and sharp both drop the EXIF orientation tag without turning the pixels. A test file stored at 400 × 300 with orientation 6 came out 400 × 300 from a plain save in both. With exif_transpose or autoOrient(), the file came out 300 × 400, upright. The Pillow question about this has 59,033 views.
  3. Transparent areas change colour. The accepted answer to “cannot write mode RGBA as JPEG”, with 167,017 views, is convert("RGB"). That shows whatever colour sits under the transparent pixels: teal, in our test logo. sharp's flatten() fills with black by default. Paste onto white instead.
  4. Colours shift. Pillow’s JPEG writer saves no colour profile unless you pass icc_profile, and it does not convert the pixels, so a Display P3 photo loses its colour space. sharp converts to sRGB before it strips the profile.
  5. The code no longer runs. Pillow 10.0.0 removed Image.ANTIALIAS, so answers that still use it stop with an AttributeError. Use Image.Resampling.LANCZOS.

What about PNG, WebP and AVIF?

WebP and AVIF take the same search. Call fit_under(path, 100_000, "AVIF") in Python, or swap .jpeg({ quality, mozjpeg: true }) for .avif({ quality }) in sharp. Pillow's standard wheels write AVIF since version 11.3.0. As AVIF, the 100 KB cap kept all 4964 × 2782 pixels of our test photo in Pillow, at 98,345 bytes and quality 27. AVIF costs time: in sharp, at its default effort, the same search took 24.51 seconds against 2.59 for JPEG, so resize first. A search also survives encoder updates: sharp 0.35.0 retuned AVIF output with SSIMULACRA2 metrics, so the same AVIF quality can give different bytes after an upgrade.

PNG has no quality setting. Pillow ignores quality=10 on a PNG without a warning: our photo-like PNG came out at 1,034,666 bytes either way. The dial is the colour count, and it does not move in order: 16 colours came to 103,460 bytes and 17 colours to 71,609. Try each colour count in turn, or save photos as JPEG, WebP or AVIF.

How much does the search cost?

Time grows with the number of encodes times the pixels in each. sharp’s 100 KB run took 7 encodes, all at full size, and the 50 KB runs took 14 in both libraries. Most of sharp’s time is MozJPEG: one full-size encode took 0.43 seconds with mozjpeg: true and 0.05 seconds without, for 307,919 bytes against 424,762 at quality 50. Pillow's full-size encode took 0.045 seconds.

The cheapest speed-up is to resize to the size the picture will be shown at, before the search. Add the first line to fit_under right after the with block, or the second after .autoOrient() in Node.js:

img.thumbnail((1920, 1920), Image.Resampling.LANCZOS)  # the size it will be shown at
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })

With the resize, sharp held the photo under 100 KB at 1920 × 1076 and quality 66, in 0.85 seconds instead of 2.59. Pillow found quality 51 at the same size in 0.30 seconds.

For uploads you do not control, keep the decoders’ limits on. sharp refuses inputs over 268,402,689 pixels by default. Pillow warns above 89,478,485 pixels and raises DecompressionBombError above twice that.

Can one API call do this?

Yes. Blinkimg’s image API takes the cap as one field, targetBytes, and runs the same kind of search on our server. The file comes back at or under the cap, or the job fails with a named error such as TARGET_SIZE_UNREACHABLE, never as an overweight file. A job takes six requests: an upload slot, the bytes, the job, polling, a signed link and the file. The upload URL lasts 15 minutes and the download link an hour at most.

BLINKIMG_KEY is a placeholder for your own API key, which starts with bk_ and comes from your account. With curl and jq:

#!/usr/bin/env bash
# blinkimg-fit.sh photo.jpg 100000: the same cap through Blinkimg's API (curl, jq)
set -euo pipefail
: "${BLINKIMG_KEY:?set BLINKIMG_KEY to your API key}"
API=https://api.blinkimg.com/v1
SRC=$1 MAX=$2
api() {  # one call with your key; prints the answer, or its error and stops
local out
out=$(curl -sS -H "Authorization: Bearer $BLINKIMG_KEY" "$@")
if jq -e .error <<< "$out" > /dev/null; then # an HTTP error, or a failed job
jq -r '.error | "\(.code): \(.message)"' <<< "$out" >&2; exit 1
fi
echo "$out"
}
# 1. An upload slot for exactly this many bytes, 2. the bytes (no key on this URL)
UPLOAD=$(api -X POST "$API/uploads" -H "Content-Type: application/json" \
-d "{\"filename\": \"$(basename "$SRC")\", \"contentLength\": $(wc -c < "$SRC")}")
curl -sS --fail-with-body -X PUT --upload-file "$SRC" "$(jq -r .url <<< "$UPLOAD")"
# 3. The job: JPEG, at or under MAX bytes
JOB=$(api -X POST "$API/jobs" -H "Content-Type: application/json" \
-d "{\"uploadId\": $(jq .uploadId <<< "$UPLOAD"),
\"options\": {\"format\": \"jpeg\", \"targetBytes\": $MAX}}")
ID=$(jq -r .id <<< "$JOB")
# 4. Poll until it has succeeded (a failed job stops inside api)
while [[ $(jq -r .status <<< "$JOB") =~ ^(queued|processing)$ ]]; do
sleep 1
JOB=$(api "$API/jobs/$ID")
done
# 5. A signed link, 6. the file (no key on this URL either)
LINK=$(api "$API/jobs/$ID/result" | jq -r .url)
curl -sS --fail-with-body --no-clobber -o "${SRC%.*}-api.jpg" "$LINK"
jq -r '.result | "\(.outputBytes) bytes, \(.width) x \(.height)"' <<< "$JOB"

With Python and requests:

# blinkimg_fit.py photo.jpg 100000: the same cap through Blinkimg's API (requests)
import os
import sys
import time
from pathlib import Path
import requestsAPI = "https://api.blinkimg.com/v1"
AUTH = {"Authorization": f"Bearer {os.environ['BLINKIMG_KEY']}"} # your key, bk_...
def api(method, path, **kwargs):
"""One call with your key; raises with the API's own error code."""
r = requests.request(method, API + path, headers=AUTH, timeout=30, **kwargs)
body = r.json()
if not r.ok or body.get("error"): # an HTTP error, or a failed job
raise RuntimeError("{code}: {message}".format(**body["error"]))
return body
src, max_bytes = Path(sys.argv[1]), int(sys.argv[2])
data = src.read_bytes()
upload = api("POST", "/uploads", json={"filename": src.name, "contentLength": len(data)})
requests.put(upload["url"], data=data, timeout=300).raise_for_status() # no key here
job = api("POST", "/jobs", json={"uploadId": upload["uploadId"],
"options": {"format": "jpeg", "targetBytes": max_bytes}})
while job["status"] in ("queued", "processing"):
time.sleep(1)
job = api("GET", f"/jobs/{job['id']}")
link = api("GET", f"/jobs/{job['id']}/result")
file = requests.get(link["url"], timeout=300) # no key here either
file.raise_for_status()
dst = src.with_name(f"{src.stem}-api.jpg")
with open(dst, "xb") as f: # "x": never overwrite
f.write(file.content)
r = job["result"]
print(f"{dst.name}: {r['outputBytes']:,} bytes, {r['width']} x {r['height']}")

With Node.js and its built-in fetch:

// blinkimg-fit.mjs photo.jpg 100000: the same cap through Blinkimg's API (fetch)
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
const API = 'https://api.blinkimg.com/v1';
const AUTH = { Authorization: `Bearer ${process.env.BLINKIMG_KEY}` }; // your key, bk_...
// One call with your key; throws with the API's own error code.
async function api(path, body) {
const res = await fetch(API + path, body === undefined ? { headers: AUTH } : {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok || json.error) { // an HTTP error, or a failed job
throw new Error(`${json.error.code}: ${json.error.message}`);
}
return json;
}
const [src, maxBytes] = process.argv.slice(2);
const file = await readFile(src);
const upload = await api('/uploads', { filename: basename(src), contentLength: file.length });
const put = await fetch(upload.url, { method: 'PUT', body: file }); // no key here
if (!put.ok) throw new Error(`upload: HTTP ${put.status}`);
let job = await api('/jobs', {
uploadId: upload.uploadId,
options: { format: 'jpeg', targetBytes: Number(maxBytes) },
});
while (job.status === 'queued' || job.status === 'processing') {
await new Promise((resolve) => setTimeout(resolve, 1000));
job = await api(`/jobs/${job.id}`);
}
const link = await api(`/jobs/${job.id}/result`);
const out = await fetch(link.url); // no key here either
if (!out.ok) throw new Error(`download: HTTP ${out.status}`);
const dst = src.replace(/\.\w+$/, '-api.jpg');
await writeFile(dst, Buffer.from(await out.arrayBuffer()), { flag: 'wx' }); // "wx": never overwrite
const { outputBytes, width, height } = job.result;
console.log(`${dst}: ${outputBytes.toLocaleString('en-US')} bytes, ${width} x ${height}`);

For our test photo, the engine returns 97.3 KB under 100 KB with every pixel kept, and 39.7 KB at 1371 × 768 under 50 KB. The job’s result also carries returnedOriginal, which is true when the original bytes came back untouched. A cap under 10,000 bytes is refused with a 400 BAD_REQUEST, so the 5 KB case stays with your own code.

Should you build it or call an API?

Build it when files must stay on your machine, when you need caps under 10 KB, or when your CPU time is cheaper than credits. Call an API when you need HEIC input, or when you would rather not own orientation, transparency, colour profiles and library upgrades.

Press enter or click to view image in full size
A table comparing your own code with the Blinkimg API. Price per image: your CPU time, or 1 credit with 500 free a month. Where the file goes: nowhere, or uploaded and deleted within 24 hours. Smallest cap: any, or 10,000 bytes. Over the cap: never if your code checks, or never with a named error. HEIC input: a plugin or a custom build, or yes. Orientation and transparency: yours to handle, or handled. Library upgrades: yours, or not yours
What you own on each route. API figures from Blinkimg’s pricing and API pages, September 2026; the HEIC row tested on my laptop.
  • Price: your own code costs CPU time. On Blinkimg’s pricing page, a compression costs 1 credit, a format change 2, and every account gets 500 free credits a month.
  • Privacy: your code uploads nothing. The API deletes files within 24 hours.
  • Smallest cap: your code can go as low as the pixels allow. The API stops at 10,000 bytes.
  • HEIC: Pillow 12.3.0 and the prebuilt sharp 0.35.4 both failed to open a HEIC file here. The API reads HEIC.
  • Rate: the API shapes keyed writes at 120 a minute. Your own code runs as fast as your CPUs.

Questions people ask

How do I compress an image to 100 KB in Python?

Binary-search the JPEG quality in memory with Pillow, and keep the highest quality whose file is at or under 100,000 bytes. If quality 5 is still too big, shrink the pixels. With Pillow 12.3.0, a 9.8 MB test photo of 4964 × 2782 pixels came out at 99,800 bytes and 1882 × 1055 in 0.73 seconds.

Why does my JPEG get bigger after I save it with Pillow or sharp?

The original was saved at a lower quality than the one you chose, or you used 95 or more. A photo-like 188,259-byte test image grew to 230,949 bytes at Pillow’s quality 95 and to 203,241 bytes in sharp. Pillow’s documentation says to avoid values above 95. Check whether the original already fits before you encode.

Why is my photo sideways after compressing it?

Phones often store a photo on its side, with an EXIF tag that says how to turn it. Pillow and sharp both drop that tag when they save, without turning the pixels. Call ImageOps.exif_transpose in Pillow, or autoOrient() in sharp 0.34 and later, before you encode. Older sharp versions use rotate() with no angle.

Can I hit exactly 100 KB instead of just under it?

Not reliably, because quality moves the size in steps. On a 4964 × 2782 test photo in sharp, quality 13 gave 97,325 bytes and quality 14 gave 102,417. So 97,325 bytes was the largest file possible under 100 KB. Padding a file with empty bytes to reach a number adds nothing to the picture.

Why does a transparent PNG turn black when I save it as JPEG?

JPEG has no transparency. Pillow’s convert(“RGB”) drops the alpha channel and shows whatever colour is stored under the transparent pixels, and sharp’s flatten() fills with black by default. In Pillow, paste the image onto a white background with its alpha as the mask. In sharp, call flatten with a white background.

How many encodes does the search need?

The quality search over 5 to 95 needs 7 encodes at most. Shrinking pixels adds a few more: a 4964 × 2782 test photo took 14 encodes in both Pillow and sharp under a 50 KB cap. Each encode costs time in proportion to its pixels, so resize to the display size before you search.

What if a form also needs a minimum file size?

Search the other way: find the lowest quality whose file reaches the floor, and never pad the file. The Blinkimg API takes minBytes beside targetBytes, and the file lands between the two or fails with a named error. A 600 × 600 crop of a 9.8 MB test photo, held over a 54 KB floor, came out at 59.2 KB.

Can Pillow save AVIF and WebP under a size limit?

Yes. Pillow 11.3.0 and later write AVIF from the standard wheels, and WebP support is built in too, so the same binary search works with a different format name. Under 100 KB, AVIF kept all 4964 × 2782 pixels of a test photo at 98,345 bytes, where JPEG from Pillow had to shrink to 1882 × 1055.

What to do next

Copy the function for your stack, and add the resize line if you know the display size. Run it on the 10 largest images you handle this week, and check the bytes, pixels and time it prints. If you only need one file done today, reduce an image to a size in KB in your browser.

To skip the code, Blinkimg’s API takes the cap as targetBytes, and every account gets 500 free API credits a month. If you compress every day, Blinkimg Pro costs $2.50 a month and adds 1,000 API credits a month to the free 500.

--

--

I build Blinkimg (blinkimg.com), a free image compressor and converter for JPG, PNG, WebP, AVIF and HEIC. I test what images really weigh.

See more from me