
The bug report is always some version of this: a user uploads a portrait photo from a phone, the preview in the upload form looks right, and the thumbnail our backend generates is lying on its side. Frontend says the image is fine, backend says it saved exactly what it received, and both are telling the truth. The photo's pixels are stored sideways and a single EXIF tag says how to turn them. The browser reads that tag and Pillow's default save throws it away. Reproducing it on a test photo, I also found that the textbook fix rotates the image twice, and that once you start writing EXIF on purpose you have to decide what to do with GPS.
Why the preview is upright
CSS image-orientation has an initial value of from-image, and MDN's description is that the EXIF information in the image "is used to rotate the image appropriately". That became the default in Chrome 81 and, according to Mozilla's bug tracker, in Firefox 77, so any current <img> shows a phone photo the way the phone meant it. I checked in HeadlessChrome 149 with a synthetic test photo I generated (4032×3024 stored, Orientation 6): naturalWidth × naturalHeight came back 3024×4032, already turned, and createImageBitmap reported the same, since its imageOrientation also defaults to from-image. One caveat from MDN is worth knowing if you ever try to switch this off: image-orientation: none does not override the orientation of images from a non-secure origin. The preview isn't lying. It's applying metadata the server is about to delete.
Two ways to get it wrong on the server
The first is the plain save(). Pillow writes no EXIF unless you pass it, so Orientation disappears but the pixels stay 4032×3024. The second is the fix most people reach for next: call ImageOps.exif_transpose() to rotate the pixels, then pass the original EXIF back to keep camera data. The pixels are now upright, but the copied block still says Orientation 6, so every viewer that respects the tag rotates the already-rotated image another 90 degrees. Here are both, on the same synthetic upload, read back with Pillow for the stored size and the tag, and with piexif for the GPS block:
| File | Stored pixels | Orientation | GPS |
|---|---|---|---|
| The upload | 4032×3024 | 6 | yes |
Plain save()
|
4032×3024 | none | no |
exif_transpose() + original EXIF |
3024×4032 | 6 | yes |
Look at the GPS column. The sideways version is the one without location, and the "fixed" version quietly kept it. So fixing the rotation bug the obvious way is also what brought the location back into storage. The test photo's GPS is a made-up point at sea, but a real upload would carry whatever coordinates the phone recorded.
GPS is a decision, not a side effect
I spent three months in a data-compliance cleanup a while ago, and the thing I took from it is that data we hold by accident is still data we hold. Whether coordinates in a user's photo count as personal data under the law that applies to you is for whoever owns compliance on your side to answer, and I'm not going to guess it for every jurisdiction. What I can control is that the answer lives in code as an explicit list, not in whichever Pillow call someone happened to write. For product and avatar images my default is to keep a handful of camera fields and drop everything else, GPS and the embedded thumbnail included, because nothing downstream uses them.
normalize() rotates the pixels with exif_transpose() so the tag is honoured exactly once, shrinks the image to 1600 px on the long side, and then builds a fresh EXIF block from an allowlist instead of editing the old one. The allowlist is Make and Model from the main IFD plus DateTimeOriginal, ExposureTime, FNumber and ISOSpeedRatings from the Exif IFD. The part that matters (excerpt; old is the source EXIF parsed with piexif):
new = {"0th": {k: v for k, v in old["0th"].items() if k in KEEP_0TH},
"Exif": {k: v for k, v in old["Exif"].items() if k in KEEP_EXIF},
"GPS": {}, "1st": {}, "thumbnail": None}
new["0th"][piexif.ImageIFD.Orientation] = 1
After that the two dimension fields are set to the new width and height, and the file is saved at quality 85 with the new block and the source's ICC profile passed through explicitly. The check next to it opens the source and the stored file and asserts on what it reads back (excerpt; s is the source, d the stored file, e its parsed EXIF):
upright = ImageOps.exif_transpose(s).size
assert (d.width > d.height) == (upright[0] > upright[1]), "aspect flipped"
assert e["0th"].get(piexif.ImageIFD.Orientation, 1) == 1, "orientation tag left"
assert not e["GPS"], "GPS survived"
assert not e["thumbnail"], "old thumbnail survived"
assert "icc_profile" in d.info, "ICC dropped"
I ran it on the synthetic upload (Python 3.9, Pillow 11.3, piexif 1.1.3): the 4032×3024 source with tag 6 came out as a 1200×1600 file with 9 tags kept, and the check passed. Then I pointed check() at the two broken files from the previous section, so I know the assertions actually fail when they should. The plain save() output failed on "aspect flipped", and the transpose-plus-old-EXIF output failed on "orientation tag left".
The 9 tags are Make, Model, Orientation, the four exposure fields and the two new dimensions. The ICC line is there because the plain save() drops the colour profile along with the EXIF, and a product shot tagged with a wide-gamut profile can look off without it. The check() is the part I care about more than normalize(). It belongs in CI next to fixture photos that include at least one with Orientation 6 and GPS, and it reads the output file back instead of trusting what the code intended to write.
The acceptance list I review against
- A portrait upload with Orientation 6 is stored with upright pixels and Orientation 1 or no tag, and the stored aspect ratio matches what the browser showed.
- No GPS IFD in anything we store, unless a written requirement says otherwise.
- No IFD1 thumbnail carried over from the source.
- Colour profile preserved when the source had one.
- Every rule is checked by reading the saved file, in CI, with at least one fixture that has both rotation and location.
If you resize on the client with canvas instead, the picture changes again. In my Chromium test, drawing a JPEG to canvas and calling toBlob gave upright pixels with no EXIF, GPS or XMP at all, which is fine for privacy but loses the camera fields. The HTML spec doesn't say whether drawImage should honour EXIF orientation (the WHATWG issue is still open), and MDN notes that some older browsers ignore it, so I wouldn't lean on that behaviour for users on old devices. Server-side normalisation with a read-back check is the version I'm comfortable signing off on.
Top comments (2)
The read-back assertion is the real contribution here.
normalize()is a dozen lines anyone can write, but pointingcheck()at the two broken outputs to prove the assertions actually fire is what makes the test worth keeping. Most image pipelines assert on what the code intended to write rather than what the file contains.The part that keeps biting is your GPS column: the obvious fix (transpose the pixels, then hand back the original EXIF so nothing is "lost") is what silently reintroduces location, and Orientation=1 on already-rotated pixels is the only thing keeping it upright. Did you settle on an allowlist-built block for everything you store, and does the client-side canvas path get treated as acceptable for privacy even with the WHATWG orientation issue still open?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.