sharp refuses any image over 268,402,689 pixels by default, and you lower that with limitInputPixels. Pillow warns above 89,478,485 pixels and only raises an error above twice that, so a 196-megapixel PNG fails but a 150-megapixel one opens with a warning you probably never see. Set the sharp cap explicitly, and in Pillow turn DecompressionBombWarning into an error, or your cap is half as strict as you think.
Why does a small PNG need a pixel cap?
File size says nothing about decoded size. I wrote four single-colour PNGs with sharp:
flat-9000.png 9000 x 9000 = 81 MP 253.8 KB
flat-10000.png 10000 x 10000 = 100 MP 311.2 KB
flat-14000.png 14000 x 14000 = 196 MP 601.8 KB
flat-17000.png 17000 x 17000 = 289 MP 880.3 KB
Then I measured peak memory (/usr/bin/time -l, maximum resident set size) for a full decode of the 14000 × 14000 file:
| Operation on flat-14000.png | Peak RSS |
|---|---|
Pillow Image.open() then load()
|
814 MB |
sharp .raw().toBuffer()
|
689 MB |
sharp .resize(1920).webp()
|
232 MB |
A 601.8 KB upload cost 814 MB of memory. Ten of those at once on a 4 GB worker and the process dies. A file size limit alone does not protect you; a pixel limit does.
How does limitInputPixels work in sharp?
The sharp constructor docs give the default as 268,402,689 (0x3FFF × 0x3FFF). It takes a number of pixels, or false or 0 to remove the limit. I ran each file at the default and at 100 megapixels:
// sharp-limits.mjs
import sharp from 'sharp';
const files = ['flat-9000.png', 'flat-10000.png', 'flat-14000.png', 'flat-17000.png'];
const short = (e) => e.message.split('\n')[0];
for (const limit of [undefined, 100_000_000]) {
console.log(`\nlimitInputPixels: ${limit ?? 'default (268402689)'}`);
for (const f of files) {
const opts = limit ? { limitInputPixels: limit } : {};
let meta, out;
try {
const m = await sharp(f, opts).metadata();
meta = `${m.width} x ${m.height}`;
} catch (e) { meta = short(e); }
try {
const t0 = performance.now();
await sharp(f, opts).resize(1920).webp().toBuffer();
out = `resized in ${Math.round(performance.now() - t0)} ms`;
} catch (e) { out = short(e); }
console.log(`${f.padEnd(15)} metadata: ${meta.padEnd(14)} pipeline: ${out}`);
}
}
limitInputPixels: default (268402689)
flat-9000.png metadata: 9000 x 9000 pipeline: resized in 270 ms
flat-10000.png metadata: 10000 x 10000 pipeline: resized in 300 ms
flat-14000.png metadata: 14000 x 14000 pipeline: resized in 407 ms
flat-17000.png metadata: Input image exceeds pixel limit pipeline: Input image exceeds pixel limit
limitInputPixels: 100000000
flat-9000.png metadata: 9000 x 9000 pipeline: resized in 266 ms
flat-10000.png metadata: 10000 x 10000 pipeline: resized in 309 ms
flat-14000.png metadata: Input image exceeds pixel limit pipeline: Input image exceeds pixel limit
flat-17000.png metadata: Input image exceeds pixel limit pipeline: Input image exceeds pixel limit
Three things to take from that:
-
The check happens at the header. Even
metadata()throws, and it threw in a median of 0.19 ms over 5 runs on the 289-megapixel file. You pay nothing for a refused file. - The limit is "exceeds", not "reaches". The 10000 × 10000 file is exactly 100,000,000 pixels and passed a cap of 100,000,000.
-
It applies to
createtoo. My first try at making the 17000 × 17000 test image failed with the same error until I passedlimitInputPixels: falseto the constructor.
How does MAX_IMAGE_PIXELS work in Pillow?
The Pillow Image reference says it warns with DecompressionBombWarning over MAX_IMAGE_PIXELS, and raises DecompressionBombError when the pixel count is greater than twice that. None disables the check.
# pil_limits.py
import warnings
from PIL import Image
files = ['flat-9000.png', 'flat-10000.png', 'flat-14000.png', 'flat-17000.png']
print('default MAX_IMAGE_PIXELS:', Image.MAX_IMAGE_PIXELS)
def probe(limit):
Image.MAX_IMAGE_PIXELS = limit
print(f'\nMAX_IMAGE_PIXELS = {limit}')
for f in files:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
try:
with Image.open(f) as im:
result = f'opened {im.size[0]} x {im.size[1]}'
except Image.DecompressionBombError as e:
result = f'DecompressionBombError: {str(e)[:70]}'
warned = [w.category.__name__ for w in caught]
print(f'{f:<15} {result} warnings={warned}')
probe(89_478_485)
probe(100_000_000)
default MAX_IMAGE_PIXELS: 89478485
MAX_IMAGE_PIXELS = 89478485
flat-9000.png opened 9000 x 9000 warnings=[]
flat-10000.png opened 10000 x 10000 warnings=['DecompressionBombWarning']
flat-14000.png DecompressionBombError: Image size (196000000 pixels) exceeds limit of 178956970 pixels, could warnings=[]
flat-17000.png DecompressionBombError: Image size (289000000 pixels) exceeds limit of 178956970 pixels, could warnings=[]
MAX_IMAGE_PIXELS = 100000000
flat-9000.png opened 9000 x 9000 warnings=[]
flat-10000.png opened 10000 x 10000 warnings=[]
flat-14000.png opened 14000 x 14000 warnings=['DecompressionBombWarning']
flat-17000.png DecompressionBombError: Image size (289000000 pixels) exceeds limit of 200000000 pixels, could warnings=[]
Look at the second run. I set the cap to 100 megapixels, and the 196-megapixel file still opened. The error threshold is 200,000,000, twice the number I set.
Note: In Pillow,
MAX_IMAGE_PIXELSis a warning threshold, not a limit. Python shows a given warning once per location by default, and many servers send warnings to a log nobody reads. Make it fatal withwarnings.simplefilter('error', Image.DecompressionBombWarning), as Pillow's docs suggest.
With that one line added:
import warnings
from PIL import Image
Image.MAX_IMAGE_PIXELS = 100_000_000
warnings.simplefilter('error', Image.DecompressionBombWarning)
flat-10000.png opened (10000, 10000)
flat-14000.png DecompressionBombWarning
Now the cap means what it says. Catch both DecompressionBombWarning and DecompressionBombError and answer with a 4xx.
What cap should you set?
Size it from memory, not from what feels big. A full RGB decode needs width × height × 3 bytes before any library overhead: 588 MB for the 14000 × 14000 file, and Pillow peaked at 814 MB. So:
- Pick a number your worker can hold a few of at once. A 100-megapixel cap means up to 300 MB of RGB per decode before overhead. Multiply by your concurrency.
- Set it in both places when you use both libraries. sharp's default allows 268 megapixels; Pillow's error starts at 179 megapixels. A file can pass one and fail the other.
- Resize in the same sharp pipeline. The resize to 1920 peaked at 232 MB against 689 MB for the raw decode, because libvips streams the image instead of holding it all.
Our own API's plans document lists maxPixels: 100000000 for the free plan. The same cap guards our PNG compressor, where a flat PNG like these can hide a huge image in a small file.
How I measured
- Files: four single-colour PNGs made with sharp's
createat compression level 9, 9000 × 9000 to 17000 × 17000. - sharp 0.35.4 (libvips 8.18.6) on Node 24.22.0. Pillow 11.3.0 on Python 3.9.6, in a venv.
- Memory: maximum resident set size from
/usr/bin/time -l, one process per run. Baselines were 44 MB for an empty Node process and 26 MB for Python importing Pillow. - Time: the resize times in the output are single runs; the 0.19 ms refusal is a median of 5. Medians of 5 for the resizes were 265 ms (81 MP) and 401 ms (196 MP).
- Apple M1 Pro, macOS, 2026-10-17.
What I did not test
Real photographs, where decode memory and time are higher than for a flat colour. I did not test JPEG shrink-on-load, sharp's unlimited option, or Pillow's Image.draft().
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)