Strip EXIF, XMP and IPTC, but never drop the ICC profile unless you convert the pixels to sRGB first. A phone photo in Display P3 that loses its profile is read as sRGB, and the colour goes flat. In my test, Pillow's default save() cut average saturation from 55.3 to 49.4. sharp's default converts to sRGB before it strips, so it stays close at 54.2.
Why does dropping the profile change the colour?
An ICC profile tells the viewer what the numbers in each pixel mean. The same RGB triple is a more saturated green in Display P3 than in sRGB. When the profile goes, every browser falls back to sRGB and reads the P3 numbers as sRGB numbers. Nothing is "lost" in the bytes. The meaning is.
Phone photos often carry a Display P3 profile, like the one below. A "strip all metadata" step that treats the profile like GPS data quietly dulls every one of them.
What does each library do by default?
I took a 2250 × 4000 phone photo whose embedded profile is Apple's "Display P3" (548 bytes), resized it to 1200 px wide, and saved it as a quality-80 JPEG in several ways.
sharp first. Its output docs say all metadata is removed by default, and keepIccProfile() keeps the input profile:
import sharp from 'sharp';
import { writeFile } from 'node:fs/promises';
const src = 'p3.jpg';
const base = () => sharp(src).resize({ width: 1200 });
const variants = {
default: base().jpeg({ quality: 80 }),
keepIcc: base().keepIccProfile().jpeg({ quality: 80 }),
srgbTagged: base().withIccProfile('srgb').jpeg({ quality: 80 }),
ignore: sharp(src, { ignoreIcc: true }).resize({ width: 1200 }).jpeg({ quality: 80 }),
};
for (const [name, pipe] of Object.entries(variants)) {
const buf = await pipe.toBuffer();
await writeFile(`${name}.jpg`, buf);
const m = await sharp(buf).metadata();
console.log(name.padEnd(11), String(buf.length).padStart(7), 'bytes icc:', m.icc ? m.icc.length + ' bytes' : 'none');
}
default 323884 bytes icc: none
keepIcc 321937 bytes icc: 548 bytes
srgbTagged 324382 bytes icc: 480 bytes
ignore 321371 bytes icc: none
Then Pillow. Its JPEG docs are plain about it: without icc_profile, the image is saved with no profile attached. Pillow does not convert the pixels either.
from PIL import Image
im = Image.open("p3.jpg")
icc = im.info.get("icc_profile")
im = im.resize((1200, round(im.height * 1200 / im.width)), Image.LANCZOS)
im.save("pil_default.jpg", quality=80) # profile gone
im.save("pil_keep.jpg", quality=80, icc_profile=icc) # profile kept
pil_default.jpg 325604 bytes icc: none
pil_keep.jpg 326170 bytes icc: 548
How do you measure "flat"?
I rendered each file the way a colour-managed viewer does: apply its profile, land in sRGB. Then I took the mean of each channel and an average saturation (max channel minus min channel, per pixel).
import sharp from 'sharp';
async function look(input) {
const { data } = await input.toColourspace('srgb').raw().toBuffer({ resolveWithObject: true });
let r = 0, g = 0, b = 0, sat = 0;
const n = data.length / 3;
for (let i = 0; i < data.length; i += 3) {
r += data[i]; g += data[i + 1]; b += data[i + 2];
sat += Math.max(data[i], data[i + 1], data[i + 2]) - Math.min(data[i], data[i + 1], data[i + 2]);
}
return `R ${(r / n).toFixed(1)} G ${(g / n).toFixed(1)} B ${(b / n).toFixed(1)} saturation ${(sat / n).toFixed(1)}`;
}
console.log('source ', await look(sharp('p3.jpg').resize({ width: 1200 })));
for (const f of ['default', 'keepIcc', 'pil_keep', 'pil_default', 'pil_srgb', 'ignore'])
console.log(f.padEnd(11), await look(sharp(`${f}.jpg`)));
source R 102.5 G 123.5 B 71.5 saturation 55.3
default R 102.5 G 123.5 B 72.7 saturation 54.2
keepIcc R 102.5 G 123.5 B 71.6 saturation 55.3
pil_keep R 102.6 G 123.6 B 71.7 saturation 55.4
pil_default R 107.7 G 123.1 B 77.0 saturation 49.4
pil_srgb R 102.6 G 123.6 B 72.7 saturation 54.3
ignore R 107.6 G 123.0 B 76.9 saturation 49.4
| Pipeline | Bytes | Profile in file | Saturation |
|---|---|---|---|
| Source, resized only | 55.3 | ||
sharp keepIccProfile()
|
321,937 | Display P3, 548 B | 55.3 |
Pillow icc_profile=icc
|
326,170 | Display P3, 548 B | 55.4 |
| sharp default (converts to sRGB) | 323,884 | none | 54.2 |
| Pillow, ImageCms to sRGB | 328,215 | none | 54.3 |
Pillow save() default |
325,604 | none | 49.4 |
sharp ignoreIcc: true
|
321,371 | none | 49.4 |
The broken files sit at 49.4, about 11% less saturated, with red and blue pushed up. The sharp default lands at 54.2, a touch below the source. Its conversion to sRGB clips the P3 greens that sRGB cannot hold. That is the honest cost of sRGB, not a bug.
Note: The profile costs 548 bytes here, about 0.2% of a 322 KB JPEG. Keeping it is never the reason a file is too big. Stripping it to "save space" is the mistake.
Which fix should you pick?
You have two correct options. Both remove EXIF, XMP and IPTC.
Keep the profile. Wide-gamut screens show the full P3 colour. In sharp, add keepIccProfile(). In Pillow, pass icc_profile=im.info.get("icc_profile") to save().
Convert to sRGB, then drop it. Every screen shows the same colour, and the file needs no profile. sharp's default already does this. In Pillow you do it yourself with ImageCms:
import io
from PIL import Image, ImageCms
im = Image.open("p3.jpg")
icc = im.info.get("icc_profile")
im = im.resize((1200, round(im.height * 1200 / im.width)), Image.LANCZOS)
if icc:
src = ImageCms.ImageCmsProfile(io.BytesIO(icc))
dst = ImageCms.createProfile("sRGB")
im = ImageCms.profileToProfile(im, src, dst, outputMode="RGB")
im.save("pil_srgb.jpg", quality=80) # no profile needed: the pixels are sRGB now
That file printed 328,215 bytes and saturation 54.3, level with sharp's default.
The one pipeline to avoid is the one that drops the profile and keeps the numbers. In sharp you only get there on purpose, with { ignoreIcc: true }. In Pillow it is the default.
Where does this bite in practice?
- An upload handler in Python that re-saves every image "to strip GPS" with a bare
save(). - A sharp pipeline someone "fixed" with
ignoreIcc: trueto silence a colour-space warning. - A thumbnail job that copies EXIF by hand and forgets the profile, which lives in its own segment.
A quick check for any pipeline: read the output with sharp(buf).metadata(). If the input had icc and the output has none, the pixels must have been converted. Compare a saturation number like the one above to be sure.
I run the same kind of before-and-after check on every number Blinkimg publishes; how we measure lists the method.
How I measured
- Machine: Apple M1 Pro, macOS, 2026-10-17.
- Node v24.22.0, sharp 0.35.4, libvips 8.18.6, lcms 2.19.1.
- Python 3.9.6, Pillow 11.3.0 in a fresh venv.
- Source: a 2250 × 4000 phone JPEG from the Blinkimg image library, embedded profile "Display P3" (Apple, 548 bytes), no EXIF.
- Every output: resized to 1200 px wide, JPEG quality 80. Saturation is computed after applying each file's profile and converting to sRGB with sharp.
What I did not test
PNG, WebP and AVIF outputs, 16-bit sources, and CMYK profiles. I did not measure how browsers on non-P3 screens render the P3 file.
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)