How to strip GPS coordinates from photo metadata in Python

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")

# Or remove all EXIF
piexif.remove("photo.jpg")

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. In a round-trip test, a JPEG with six GPS tags came back with zero after the strip, the camera model survived as intended, and the file stayed 391 KB.

Stack showing the JPEG structure with 0th, Exif, GPS, Interop and thumbnail metadata blocks alongside the image data.
GPS is one of five blocks; stripping it leaves the other four, including the thumbnail.

Step-by-step solution

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. Some strippers leave it. 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.

Flow from audit through strip, verification and manifest to a released folder with originals held back.
The verification pass is the step that turns a script into a control.

Code examples

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

Example 2 โ€” read the coordinates, then remove them

import piexif

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.822531, -0.137164
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

Raising is the point. A stripping pass that reports success on files it failed to open is the failure this whole guide is about.

Explanation

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 โ€” a test on a stripped file confirmed no exif key remained. But it re-encodes the JPEG at whatever quality you specify, which loses detail, changes the file size and 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.

Comparison grid of clearing the GPS block, piexif.remove and a Pillow re-save across whether each removes GPS, keeps the camera tags, leaves pixels identical and clears the thumbnail.
Editing the metadata segment keeps the pixels; re-saving does not.

Edge cases or notes

  • 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 1st IFD as well as GPS.
  • 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.

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.