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

The metadata blocks inside a JPEG alongside the compressed image data. what piexif.load returns0thmake, model, orientationidentifies the cameraExifDateTimeOriginal, serialidentifies the momentGPSlat, lon, altitude, dateidentifies the placeInteropcolour spaceharmless1st + thumbnaila whole small JPEGoften left behindStripping GPS alone leaves four blocks, one of them a complete image spatialworkflow.io
GPS is one of five blocks; stripping it leaves the other four, including the thumbnail.

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.

The stages of a photograph metadata strip, ending in verification. strip and verify1audit the foldercount what is there2copy, do not moveoriginals stay controlled3edit the metadata segmentnot a Pillow re-save4re-read and raise on any GPS tagthe actual controlA strip that cannot fail is a strip that silently skipped a file spatialworkflow.io
The verification pass is the step that turns a script into a control.

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.

Three ways to remove EXIF compared on what they keep and what they cost. three ways to stripGPS = {}piexif.removePillow re-saveremoves GPSโœ“yesโœ“yesโœ“yeskeeps cameraโœ“yesโœ•noโœ•nopixels identicalโœ“yesโœ“yesโœ•noclears thumbnail!set itโœ“yesโœ“yesOnly the re-save changes the image data spatialworkflow.io
Editing the metadata segment keeps the pixels; re-saving does not.

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 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.