How to strip GPS coordinates from photo metadata in Python
On this page
Problem statement
Photographs attached to a spatial dataset โ survey evidence, damage assessments, species records, street audits โ carry their own coordinates in EXIF, and those coordinates are the ones the camera recorded, not the ones you published. A dataset whose points have been carefully masked ships with a folder of JPEGs pointing at the true locations, and nothing in the publishing pipeline notices.
The metadata also carries a timestamp, a camera make and model, and often a serial number. Together those link photographs across datasets and identify the photographer as reliably as the coordinates identify the subject.
This guide reads the tags, removes the ones that matter, and verifies the removal, with the specific traps: Pillow re-saves that recompress, tools that leave a thumbnail with its own EXIF, and formats that hide location somewhere other than EXIF.
Quick answer
import piexif
# Read what is there
tags = piexif.load("photo.jpg")
print("GPS tags:", len(tags["GPS"]))
# Remove only GPS, keep the rest
tags["GPS"] = {}
piexif.insert(piexif.dump(tags), "photo.jpg")
print("GPS tags after:", len(piexif.load("photo.jpg")["GPS"]))
# Or remove all EXIF
piexif.remove("photo.jpg")
GPS tags: 8
GPS tags after: 0
piexif.insert rewrites the metadata block in place without touching the image data, so the pixels are bit-identical and there is no generation loss. On the photograph used here, eight GPS tags came back as zero, the camera model survived as intended, the file went from 420,180 to 419,692 bytes, and the decoded pixels were identical (Example 4).
Audit, decide what to remove, then strip
1. Audit before you strip
Know what is in the folder. Count files with GPS tags, with a serial number, and with a timestamp, so the report says what was removed rather than what you hoped was there.
2. Decide between removing GPS and removing everything
Removing only GPS keeps the camera, the lens and the date, which are useful for provenance and are usually not sensitive. Removing everything is safer and loses the evidence trail. For a public release, remove everything and keep the originals in a controlled location with a manifest.
3. Convert GPS tags correctly if you need to read them first
EXIF stores latitude and longitude as three rationals โ degrees, minutes, seconds โ plus a hemisphere reference. Forgetting the reference gives you a point in the wrong hemisphere.
def to_degrees(triple, ref):
d, m, s = (a / b for a, b in triple)
value = d + m / 60 + s / 3600
return -value if ref in (b"S", b"W") else value
4. Strip in place, not by re-saving
piexif.insert and piexif.remove edit the metadata segment. Opening with Pillow and calling save re-encodes the JPEG โ it does drop EXIF, which a test confirms, but it also recompresses the image and changes every pixel.
5. Check for a thumbnail
The 1st IFD holds an embedded thumbnail, which is a complete small JPEG that can have its own metadata. Clearing only the GPS block leaves it: here a 160 ร 120 copy of the picture stayed in the file โ without GPS tags of its own in this case, but still an image someone may not have meant to ship. piexif.remove clears the whole block.
6. Verify, do not assume
Re-read every file after the pass and fail loudly on any remaining GPS tag. A stripper that silently skipped an unreadable file is worse than no stripper.
7. Handle the other formats
HEIC, PNG, TIFF and WebP all have metadata containers, and some carry location in XMP rather than EXIF. Convert or use a tool that understands the container; a JPEG-only pass on a mixed folder leaves the HEICs untouched.
8. Check the filenames and the folder structure
IMG_20260411_Riverside_Close.jpg is a location. So is a folder named after a household.
Reading and removing a photo's coordinates
The data used on this page
A real geotagged photograph: Gulls, Royal Pavilion Gardens, Brighton by Robin Webster, CC BY-SA 2.0, via Wikimedia Commons, whose original file still carries the camera's EXIF. The setup keeps it as original.jpg, works on a copy called photo.jpg, and builds a small survey/ folder of two copies for the folder examples.
import os
import shutil
import piexif
import requests
URL = ("https://upload.wikimedia.org/wikipedia/commons/4/40/"
"Gulls%2C_Royal_Pavilion_Gardens%2C_Brighton_-_geograph.org.uk_-_6126195.jpg")
if not os.path.exists("original.jpg"):
reply = requests.get(URL, headers={"User-Agent": "spatialworkflow-examples/1.0"}, timeout=120)
reply.raise_for_status()
open("original.jpg", "wb").write(reply.content)
shutil.copy("original.jpg", "photo.jpg")
os.makedirs("survey", exist_ok=True)
for name in ("gulls_01.jpg", "gulls_02.jpg"):
shutil.copy("original.jpg", os.path.join("survey", name))
tags = piexif.load("original.jpg")
print(f"original.jpg: {os.path.getsize('original.jpg'):,} bytes, {len(tags['GPS'])} GPS tags, "
f"camera {tags['0th'][piexif.ImageIFD.Make].decode()} {tags['0th'][piexif.ImageIFD.Model].decode()}, "
f"thumbnail {len(tags['thumbnail']):,} bytes")
original.jpg: 420,180 bytes, 8 GPS tags, camera Panasonic DMC-TZ70, thumbnail 7,031 bytes
Example 1 โ audit a folder
import pathlib, piexif
def audit(folder):
rows = []
for p in sorted(pathlib.Path(folder).glob("**/*.jpg")):
try:
tags = piexif.load(str(p))
except Exception as exc:
rows.append({"file": p.name, "error": str(exc)})
continue
rows.append({
"file": p.name,
"gps_tags": len(tags["GPS"]),
"has_datetime": piexif.ExifIFD.DateTimeOriginal in tags["Exif"],
"make_model": (tags["0th"].get(piexif.ImageIFD.Make, b"").decode(errors="replace"),
tags["0th"].get(piexif.ImageIFD.Model, b"").decode(errors="replace")),
"thumbnail": tags["thumbnail"] is not None,
})
return rows
for row in audit("survey"):
print(row)
{'file': 'gulls_01.jpg', 'gps_tags': 8, 'has_datetime': True, 'make_model': ('Panasonic', 'DMC-TZ70'), 'thumbnail': True}
{'file': 'gulls_02.jpg', 'gps_tags': 8, 'has_datetime': True, 'make_model': ('Panasonic', 'DMC-TZ70'), 'thumbnail': True}
Example 2 โ read the coordinates, then remove them
import piexif
import shutil
shutil.copy("original.jpg", "photo.jpg") # a fresh copy: the quick answer already stripped this one
tags = piexif.load("photo.jpg")
gps = tags["GPS"]
def to_degrees(triple, ref):
d, m, s = (a / b for a, b in triple)
value = d + m / 60 + s / 3600
return -value if ref in (b"S", b"W") else value
lat = to_degrees(gps[piexif.GPSIFD.GPSLatitude], gps[piexif.GPSIFD.GPSLatitudeRef])
lon = to_degrees(gps[piexif.GPSIFD.GPSLongitude], gps[piexif.GPSIFD.GPSLongitudeRef])
print(f"{lat:.6f}, {lon:.6f}")
tags["GPS"] = {}
piexif.insert(piexif.dump(tags), "photo.jpg")
print("GPS tags after:", len(piexif.load("photo.jpg")["GPS"]))
50.822680, -0.136807
GPS tags after: 0
The camera model survived the strip, which is what tags["GPS"] = {} promises; piexif.remove clears all five blocks instead.
Example 3 โ strip a folder and fail on anything left behind
import pathlib, piexif, shutil
def strip_folder(src, dst, keep_camera=False):
src, dst = pathlib.Path(src), pathlib.Path(dst)
dst.mkdir(parents=True, exist_ok=True)
report = []
for p in sorted(src.glob("**/*.jpg")):
out = dst / p.name
shutil.copy2(p, out)
if keep_camera:
tags = piexif.load(str(out))
before = len(tags["GPS"])
tags["GPS"] = {}
tags["thumbnail"] = None
piexif.insert(piexif.dump(tags), str(out))
else:
before = len(piexif.load(str(out))["GPS"])
piexif.remove(str(out))
after = len(piexif.load(str(out))["GPS"])
report.append({"file": p.name, "gps_before": before, "gps_after": after})
leaks = [r for r in report if r["gps_after"]]
if leaks:
raise RuntimeError(f"{len(leaks)} file(s) still carry GPS tags: "
f"{[r['file'] for r in leaks][:5]}")
return report
print(strip_folder("survey", "release"))
print(strip_folder("survey", "release_keep_camera", keep_camera=True))
kept = piexif.load("release_keep_camera/gulls_01.jpg")
print("kept camera:", kept["0th"].get(piexif.ImageIFD.Model), " thumbnail:", kept["thumbnail"])
[{'file': 'gulls_01.jpg', 'gps_before': 8, 'gps_after': 0}, {'file': 'gulls_02.jpg', 'gps_before': 8, 'gps_after': 0}]
[{'file': 'gulls_01.jpg', 'gps_before': 8, 'gps_after': 0}, {'file': 'gulls_02.jpg', 'gps_before': 8, 'gps_after': 0}]
kept camera: b'DMC-TZ70' thumbnail: None
Raising is the point. A stripping pass that reports success on files it failed to open is the failure this whole guide is about.
Example 4 โ what each method does to the file and the pixels
import numpy as np
from PIL import Image
pixels = lambda path: np.asarray(Image.open(path))
before = pixels("original.jpg")
def describe(label, path):
tags = piexif.load(path)
same = np.array_equal(pixels(path), before)
print(f"{label:24} {os.path.getsize(path):>9,} bytes GPS tags {len(tags['GPS'])} "
f"model {tags['0th'].get(piexif.ImageIFD.Model)} thumbnail {'yes' if tags['thumbnail'] else 'no'} "
f"pixels identical: {same}")
describe("original", "original.jpg")
shutil.copy("original.jpg", "gps_only.jpg")
tags = piexif.load("gps_only.jpg")
tags["GPS"] = {}
piexif.insert(piexif.dump(tags), "gps_only.jpg")
describe("GPS block cleared", "gps_only.jpg")
thumb = piexif.load("gps_only.jpg")["thumbnail"]
open("thumb.jpg", "wb").write(thumb)
print(f" the thumbnail left behind is a {Image.open('thumb.jpg').size} JPEG with {len(piexif.load('thumb.jpg')['GPS'])} GPS tags of its own")
shutil.copy("original.jpg", "removed.jpg")
piexif.remove("removed.jpg")
describe("piexif.remove", "removed.jpg")
Image.open("original.jpg").save("resaved.jpg", quality=90)
describe("Pillow re-save, q=90", "resaved.jpg")
diff = np.abs(pixels("resaved.jpg").astype(int) - before.astype(int))
print(f" re-save changed {np.mean(diff.max(axis=2) > 0):.1%} of pixels, by up to {diff.max()} levels; "
f"'exif' in Image.info: {'exif' in Image.open('resaved.jpg').info}")
original 420,180 bytes GPS tags 8 model b'DMC-TZ70' thumbnail yes pixels identical: True
GPS block cleared 419,692 bytes GPS tags 0 model b'DMC-TZ70' thumbnail yes pixels identical: True
the thumbnail left behind is a (160, 120) JPEG with 0 GPS tags of its own
piexif.remove 411,657 bytes GPS tags 0 model None thumbnail no pixels identical: True
Pillow re-save, q=90 331,427 bytes GPS tags 0 model None thumbnail no pixels identical: False
re-save changed 98.2% of pixels, by up to 89 levels; 'exif' in Image.info: False
Why EXIF survives so much handling
Why EXIF survives so much handling
The metadata block is separate from the compressed image data, so copying, moving, uploading and most resizing preserve it. Social platforms usually strip it on upload, which is why people believe it is fragile; a file handed over on a drive or in a zip keeps everything.
Why re-saving through Pillow is a poor strip
Image.open(p).save(q) does drop EXIF unless you pass it explicitly โ no exif key remained after a re-save here. But it re-encodes the JPEG at whatever quality you specify: at quality 90 it changed 98.2% of the pixels, by up to 89 levels, and shrank the file from 420,180 to 331,427 bytes (Example 4), which breaks any checksum-based provenance you were keeping. Edit the metadata segment instead.
Why timestamps matter as much as coordinates
A photograph taken at a known time and place is a strong quasi-identifier even without coordinates, because it can be matched against anything else that happened then. For survey work, the date is often needed; the time to the second rarely is. Coarsen it rather than deleting it if provenance matters.
Why the folder is part of the release
Photographs are usually shipped alongside the vector data, and the two are checked by different people. If the points were masked, the photographs have to move with them: either strip the coordinates, or re-tag the photograph with the masked coordinate so the two agree.
HEIC, XMP, thumbnails and filenames
- HEIC and PNG have their own containers. A JPEG-only pass leaves them intact.
- XMP can duplicate the location. Some cameras and editors write both.
- Thumbnails carry metadata. Clear the
1stIFD as well asGPS. - Filenames and folders leak. Rename on export.
- Altitude is a GPS tag too. Removing latitude and longitude alone leaves
GPSAltitude. - Faces and signage leak. Metadata stripping does not touch the pixels.
- Keep the originals. In a controlled place, with a manifest linking them to the released copies.
- Run the strip in the pipeline, not by hand. A manual step is a step that gets skipped.
Internal links
- How to run a privacy check before publishing a spatial dataset โ the gate that should be calling this
- A shared file still contains the original coordinates โ the other side channels
- Geoprivacy explained: why coordinates are personal data โ why photograph coordinates are the same problem
- How to apply donut geomasking to sensitive points in Python โ keeping photographs consistent with masked points
- How to build a GIS folder inventory and manifest in Python โ the manifest for the originals
- How to package GIS deliverables in Python โ what goes in the zip
- How to checksum spatial datasets so you can prove they match โ why re-encoding breaks provenance
- How to standardise dates in spatial data with Python โ coarsening timestamps
FAQ
How do I remove GPS data from a photo in Python?
Load the tags with piexif.load, set tags["GPS"] = {} and write them back with piexif.insert, or call piexif.remove to clear every metadata block.
Does saving the image again remove EXIF?
Pillow drops EXIF on save unless you pass it explicitly, but it also re-encodes the image. Editing the metadata segment in place keeps the pixels identical.
Will stripping EXIF change the image?
Not with piexif.insert or piexif.remove. Both rewrite only the metadata segment.
What else in a photo is identifying?
The timestamp, camera make, model and serial number, an embedded thumbnail, the filename, the folder name, and whatever is visible in the picture.
Do HEIC and PNG files have the same problem?
Yes, in different containers, and some store location in XMP rather than EXIF. A JPEG-only pass will silently skip them.
Should I delete the originals?
No. Keep them somewhere controlled with a manifest; you will need them to answer questions about the release later.