How to run a privacy check before publishing a spatial dataset
On this page
Problem statement
Every failure in this cluster is a step somebody meant to do and did not: the mask that was not applied to some of the rows, the photographs that shipped with the masked points, the detail table nobody crossed against the map, the GeoPackage that kept the source layer. A privacy review that lives in a person's head fails the moment the release is automated or the person is on holiday.
The fix is a gate: a function that takes the files you are about to publish, runs the same checks every time, and refuses to return if any of them fails. This guide builds one โ uniqueness, candidate counts, minimum published cell, masking evidence, attribute scan, hidden-layer scan and photograph scan โ and runs it on a real release: the 4,013 street-level crimes police.uk published for central Leicester in JanuaryโMarch 2025, donut-masked by 50โ250 m, with a table of counts by 250 m cell, month and category, and a site photograph. The attacker's list of candidate homes is Ordnance Survey's complete register of addresses, OS Open UPRN.
Quick answer
def privacy_gate(folder, *, layer, source, candidates, expected_layers, quasi=("month", "category")):
"""Raise unless every check passes on the files in `folder`. Returns the checks when they do."""
folder = pathlib.Path(folder)
points = gpd.read_file(folder / "crimes.gpkg", layer=layer) # read back what will ship
table = pd.read_csv(folder / "counts.csv")
checks = [
check_uniqueness(points, quasi),
check_candidate_k(points, candidates),
check_min_cell(table, ["cell", *quasi]),
check_masking_applied(points, source),
check_attributes(points),
check_hidden_layers(folder / "crimes.gpkg", expected_layers),
check_photographs(folder / "photos"),
]
failed = [c for c in checks if not c["pass"]]
if failed:
raise RuntimeError("privacy gate failed:\n" +
"\n".join(f" โ {c['name']}: {c['detail']}" for c in failed))
return checks
The important design decision is that it raises. A check that writes a warning to a log is a check that ships. Run on the release as it was first assembled, six of the seven checks fail (Example 3).
What you get โ from The data used on this page
4,013 crimes, 98.7% sharing a location; 84,402 UPRNs from osopenuprn_202609.csv
Step-by-step solution
1. Run the gate on the output, not the input
The file being checked must be the file being published, read back from disk. Checking the in-memory GeoDataFrame misses everything the writer did โ precision, layer retention, index columns. The gate above opens the GeoPackage and the CSV in the release folder, never the variables that produced them.
2. Check uniqueness at the published precision
Count how many records are alone on their combination of location and attributes at the precision the release exposes. Donut-masked coordinates are unique by construction, so counting exact coordinates says nothing; count at the resolution a reader can resolve โ here a 250 m cell together with month and category. On the Leicester release 851 of the 4,013 records are alone on that combination. Where the threshold sits is a policy decision; the gate makes it explicit and versioned.
3. Check candidate counts against a named auxiliary file
For each published point, count the addresses an attacker could attribute it to โ here, every UPRN within 100 m. Report the minimum k and the number of records below the threshold. Masking does not guarantee a floor: a 50โ250 m displacement can land a point in a park or a car park with almost nobody around it, and nine of the masked Leicester points had fewer than five addresses within 100 m โ against three of police.uk's own points. Pin the auxiliary file's version in the configuration so the number is reproducible โ OS Open UPRN is re-released every few weeks.
4. Check the smallest cell across the full cross-product
Build the crosstab of every dimension the release exposes and take the minimum non-zero value. The Leicester counts table is the stark case: of its 1,608 non-empty combinations of cell, month and category, 1,417 are below five (Example 2). A table that fine cannot be published whole; the gate is what makes somebody decide between suppressing, coarsening and dropping a dimension.
5. Check that the masking actually ran
Compare the published geometry with the source, matched on the record ID. If any location is within a metre of its original, the mask was skipped for that row โ which is exactly what happens when a filter is applied after masking, or a late correction is merged back in from the source table with its geometry. In the first Leicester release, 40 corrected rows came back that way.
6. Scan the attributes for direct identifiers
Column names and values both. A postcode, uprn, address, nhs_number or email column, or a free-text field containing a postcode pattern, should fail the gate rather than be silently dropped โ dropping hides a modelling error. Location text counts too: police.uk's On or near Church Street names the street the original point was snapped to, which undoes a 250 m displacement in one read.
7. Scan the container for extra layers and stale files
A GeoPackage is a database. Writing a masked layer into the file that already holds the source layer publishes both โ the first Leicester release did exactly this.
layers = pyogrio.list_layers("release/crimes.gpkg")[:, 0].tolist()
assert layers == ["crimes_masked"], f"unexpected layers: {layers}"
8. Scan the attached photographs and the derived surfaces
Any JPEG with GPS tags fails. Any raster derived from the points must have been derived from the masked points; record its bandwidth or cell size and check it against the protection claimed.
9. Write the report next to the data
The report is the evidence, and it dates. Store the checks, their values, the auxiliary file version, the parameters and the timestamp.
Code examples
The data used on this page
Street-level crime from data.police.uk for January to March 2025 in a box over central Leicester (1.150ยฐW to 1.115ยฐW, 52.625ยฐN to 52.645ยฐN), with each record's ID, month, category and the On or near โฆ street text, in British National Grid (source). The candidates (addresses) are every UPRN in that box plus a 0.01ยฐ margin from Ordnance Survey's OS Open UPRN (Open Government Licence; 619 MB zipped), with the release file's name kept for the report. The site photograph is a real geotagged picture from Wikimedia Commons (Robin Webster, CC BY-SA 2.0 โ of Brighton, but any photo taken with a phone or a GPS camera carries the same tags). donut() displaces each point by 50โ250 m in a random direction, uniform over the ring's area.
import io
import os
import zipfile
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
WEST, SOUTH, EAST, NORTH = -1.150, 52.625, -1.115, 52.645 # central Leicester
if not os.path.exists("crimes.gpkg"):
rows = []
for month in ("2025-01", "2025-02", "2025-03"):
reply = requests.post("https://data.police.uk/api/crimes-street/all-crime", timeout=120,
data={"poly": f"{SOUTH},{WEST}:{SOUTH},{EAST}:{NORTH},{EAST}:{NORTH},{WEST}",
"date": month})
reply.raise_for_status()
rows += [{"id": c["id"], "month": c["month"], "category": c["category"],
"street": c["location"]["street"]["name"],
"lon": float(c["location"]["longitude"]), "lat": float(c["location"]["latitude"])}
for c in reply.json()]
table = pd.DataFrame(rows).drop_duplicates("id")
gpd.GeoDataFrame(table, geometry=gpd.points_from_xy(table.lon, table.lat), crs=4326).to_crs(27700).to_file("crimes.gpkg")
source = gpd.read_file("crimes.gpkg")
if not os.path.exists("uprn.gpkg"):
if not os.path.exists("osopenuprn_csv.zip"):
with requests.get("https://api.os.uk/downloads/v1/products/OpenUPRN/downloads?area=GB&format=CSV&redirect",
stream=True, timeout=600) as reply:
reply.raise_for_status()
with open("osopenuprn_csv.zip", "wb") as out:
for chunk in reply.iter_content(1 << 20):
out.write(chunk)
with zipfile.ZipFile("osopenuprn_csv.zip") as z:
name = next(n for n in z.namelist() if n.endswith(".csv"))
with z.open(name) as f:
keep = [c[c.LONGITUDE.between(WEST - 0.01, EAST + 0.01) & c.LATITUDE.between(SOUTH - 0.01, NORTH + 0.01)]
for c in pd.read_csv(f, chunksize=2_000_000)]
uprn = pd.concat(keep)
gpd.GeoDataFrame(uprn[["UPRN"]], crs=27700,
geometry=gpd.points_from_xy(uprn.X_COORDINATE, uprn.Y_COORDINATE)).to_file("uprn.gpkg")
open("uprn_release.txt", "w").write(name)
addresses = gpd.read_file("uprn.gpkg")
AUXILIARY = open("uprn_release.txt").read()
if not os.path.exists("site_photo.jpg"):
reply = requests.get("https://upload.wikimedia.org/wikipedia/commons/4/40/"
"Gulls%2C_Royal_Pavilion_Gardens%2C_Brighton_-_geograph.org.uk_-_6126195.jpg",
headers={"User-Agent": "spatialworkflow-examples/1.0"}, timeout=120)
reply.raise_for_status()
open("site_photo.jpg", "wb").write(reply.content)
def donut(gdf, inner=50, outer=250, seed=42):
rng = np.random.default_rng(seed)
r = np.sqrt(rng.uniform(inner ** 2, outer ** 2, len(gdf)))
angle = rng.uniform(0, 2 * np.pi, len(gdf))
out = gdf.copy()
out["geometry"] = gpd.points_from_xy(gdf.geometry.x + r * np.cos(angle),
gdf.geometry.y + r * np.sin(angle), crs=gdf.crs)
return out
def counts_table(points, cell=250):
key = ((points.geometry.x // cell).astype(int).astype(str) + "_"
+ (points.geometry.y // cell).astype(int).astype(str))
return points.assign(cell=key).groupby(["cell", "month", "category"]).size().rename("n").reset_index()
print(f"{len(source):,} crimes, {source.geometry.duplicated(keep=False).mean():.1%} sharing a location; "
f"{len(addresses):,} UPRNs from {AUXILIARY}")
4,013 crimes, 98.7% sharing a location; 84,402 UPRNs from osopenuprn_202609.csv
Example 1 โ the individual checks
import itertools, pathlib, re
import numpy as np, pandas as pd, geopandas as gpd, piexif, pyogrio
from scipy.spatial import cKDTree
IDENTIFIERS = re.compile(r"postcode|zip|uprn|address|street|location|nhs|ssn|email|phone|name$", re.I)
POSTCODE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b")
def check_uniqueness(gdf, quasi, cell=250, max_alone=0.5):
key = pd.Series(list(zip(gdf.geometry.x // cell, gdf.geometry.y // cell, *[gdf[c] for c in quasi])))
alone = int((key.value_counts() == 1).sum())
return {"name": "uniqueness", "pass": alone / len(gdf) <= max_alone,
"detail": f"{alone:,} of {len(gdf):,} records alone in their {cell} m cell with {', '.join(quasi)}"}
def check_candidate_k(gdf, candidates, min_k=5, radius=100):
tree = cKDTree(np.c_[candidates.geometry.x, candidates.geometry.y])
k = np.array([len(hits) for hits in tree.query_ball_point(np.c_[gdf.geometry.x, gdf.geometry.y], radius)])
return {"name": "candidate k", "pass": bool(k.min() >= min_k),
"detail": f"min k {k.min()} at {radius} m; {int((k < min_k).sum())} below {min_k}"}
def check_masking_applied(published, source, key="id", tolerance=1.0):
pairs = published[[key, "geometry"]].merge(source[[key, "geometry"]], on=key, suffixes=("", "_src"))
moved = gpd.GeoSeries(pairs["geometry"], crs=published.crs).distance(
gpd.GeoSeries(pairs["geometry_src"], crs=published.crs))
same = moved < tolerance
return {"name": "masking applied", "pass": not same.any(),
"detail": f"{int(same.sum())} rows within {tolerance} m of their source location"}
def check_attributes(gdf):
bad = [c for c in gdf.columns if IDENTIFIERS.search(c)]
text = [c for c in gdf.columns if c != gdf.geometry.name and not pd.api.types.is_numeric_dtype(gdf[c])
and gdf[c].astype(str).str.contains(POSTCODE).any()]
return {"name": "attributes", "pass": not bad and not text,
"detail": f"identifier-like columns {bad}; postcodes found in {text}"}
def check_hidden_layers(path, expected):
layers = pyogrio.list_layers(path)[:, 0].tolist()
return {"name": "layers", "pass": sorted(layers) == sorted(expected),
"detail": f"found {layers}, expected {expected}"}
def check_photographs(folder):
leaks = [p.name for p in sorted(pathlib.Path(folder).glob("**/*.jpg")) if piexif.load(str(p))["GPS"]]
return {"name": "photographs", "pass": not leaks,
"detail": f"{len(leaks)} file(s) with GPS tags: {leaks[:5]}"}
masked = donut(source)
for check in (check_uniqueness(masked, ("month", "category")), check_candidate_k(masked, addresses),
check_candidate_k(source, addresses), check_attributes(masked)):
print(f"{check['name']:16} {'pass' if check['pass'] else 'FAIL'} {check['detail']}")
uniqueness pass 855 of 4,013 records alone in their 250 m cell with month, category
candidate k FAIL min k 1 at 100 m; 9 below 5
candidate k FAIL min k 4 at 100 m; 3 below 5
attributes FAIL identifier-like columns ['street']; postcodes found in []
The candidate check fails on the originals as well as on the masked points. police.uk already snaps each crime to a point on or near a street, and three of those points have fewer than five addresses within 100 m; the displacement makes it nine, by moving some points away from housing.
Example 2 โ the minimum published cell across the cross-product
def check_min_cell(table, dimensions, min_cell=5, count="n", geography="cell"):
"""Smallest non-zero count in every aggregation of the table that keeps the geography."""
worst = {"combo": None, "n": np.inf}
for r in range(1, len(dimensions) + 1):
for combo in itertools.combinations(dimensions, r):
if geography not in combo:
continue
sums = table.groupby(list(combo))[count].sum()
sums = sums[sums > 0]
if sums.min() < worst["n"]:
worst = {"combo": combo, "n": int(sums.min())}
return {"name": "min cell", "pass": worst["n"] >= min_cell,
"detail": f"smallest published cell {worst['n']} at {worst['combo']}"}
full = counts_table(masked)
print(check_min_cell(full, ["cell", "month", "category"])["detail"])
print(f"{(full['n'] < 5).sum():,} of {len(full):,} cell x month x category rows are below 5")
for combo in (["cell"], ["cell", "month"], ["cell", "category"]):
sums = full.groupby(combo)["n"].sum()
print(f" by {' x '.join(combo):22} {(sums < 5).sum():>5,} of {len(sums):,} below 5")
smallest published cell 1 at ('cell',)
1,417 of 1,608 cell x month x category rows are below 5
by cell 24 of 117 below 5
by cell x month 114 of 314 below 5
by cell x category 632 of 865 below 5
Checking every combination is what catches the case where one cross-tabulation is safe and a finer one is not. Here none of them is safe: even counts by 250 m cell alone have small values at the edges of the busy centre, and adding category makes most of the table disclosive.
Example 3 โ wiring it into a pipeline
The first release was assembled the quick way: the masked layer written into the working GeoPackage beside the source, 40 records corrected afterwards by merging from the source table, the street text kept, the full counts table, and the site photograph copied in.
import json, datetime, shutil
import warnings
def assemble(folder, points, table, photo_bytes, source_layer=None):
folder = pathlib.Path(folder)
shutil.rmtree(folder, ignore_errors=True)
(folder / "photos").mkdir(parents=True)
if source_layer is not None:
source_layer.to_file(folder / "crimes.gpkg", layer="crimes")
points.to_file(folder / "crimes.gpkg", layer="crimes_masked")
table.to_csv(folder / "counts.csv", index=False)
(folder / "photos" / "visit_01.jpg").write_bytes(photo_bytes)
return folder
late = masked.sample(40, random_state=1).index
first = masked.copy()
first.loc[late] = source.loc[late] # late corrections, merged back with their geometry
naive = assemble("release_naive", first, counts_table(masked), open("site_photo.jpg", "rb").read(), source_layer=source)
try:
privacy_gate(naive, layer="crimes_masked", source=source, candidates=addresses, expected_layers=["crimes_masked"])
except RuntimeError as err:
print(err)
privacy gate failed:
โ candidate k: min k 1 at 100 m; 9 below 5
โ min cell: smallest published cell 1 at ('cell',)
โ masking applied: 40 rows within 1.0 m of their source location
โ attributes: identifier-like columns ['street']; postcodes found in []
โ layers: found ['crimes', 'crimes_masked'], expected ['crimes_masked']
โ photographs: 1 file(s) with GPS tags: ['visit_01.jpg']
Each failure names a change to the pipeline, not to the release by hand: redraw the displacement where it leaves too few candidates, suppress the small counts, drop the street text, write a fresh GeoPackage, strip the photograph.
def donut_min_k(gdf, candidates, min_k=5, radius=100, seed=42, attempts=50):
"""Donut masking that redraws any point left with fewer than min_k candidates."""
tree = cKDTree(np.c_[candidates.geometry.x, candidates.geometry.y])
out, redrawn = donut(gdf, seed=seed), 0
for attempt in range(attempts):
k = np.array([len(h) for h in tree.query_ball_point(np.c_[out.geometry.x, out.geometry.y], radius)])
low = np.flatnonzero(k < min_k)
if not len(low):
break
redrawn += len(low)
out.iloc[low, out.columns.get_loc("geometry")] = donut(gdf.iloc[low], seed=seed + 1 + attempt).geometry.values
return out, redrawn
def publish(out="release"):
points, redrawn = donut_min_k(source, addresses)
points = points.drop(columns=["street", "lon", "lat"])
table = counts_table(points)
suppressed = int((table["n"] < 5).sum())
photo = piexif.load("site_photo.jpg")
photo["GPS"] = {}
clean_photo = io.BytesIO()
piexif.insert(piexif.dump(photo), open("site_photo.jpg", "rb").read(), clean_photo)
folder = assemble(out, points, table[table["n"] >= 5], clean_photo.getvalue())
report = privacy_gate(folder, layer="crimes_masked", source=source, candidates=addresses,
expected_layers=["crimes_masked"])
(folder / "privacy_report.json").write_text(json.dumps({
"created": datetime.datetime.now().isoformat(timespec="seconds"),
"auxiliary": AUXILIARY,
"parameters": {"donut_m": [50, 250], "min_k": 5, "radius_m": 100, "min_cell": 5, "cell_m": 250},
"redrawn_points": redrawn,
"suppressed_rows": suppressed,
"checks": report,
}, indent=2))
return folder, report, redrawn, suppressed
folder, report, redrawn, suppressed = publish()
print(f"{redrawn} displacements redrawn, {suppressed:,} table rows suppressed")
for check in report:
print(f" โ {check['name']:16} {check['detail']}")
print(sorted(p.name for p in folder.rglob("*") if p.is_file()))
9 displacements redrawn, 1,412 table rows suppressed
โ uniqueness 851 of 4,013 records alone in their 250 m cell with month, category
โ candidate k min k 5 at 100 m; 0 below 5
โ min cell smallest published cell 5 at ('cell',)
โ masking applied 0 rows within 1.0 m of their source location
โ attributes identifier-like columns []; postcodes found in []
โ layers found ['crimes_masked'], expected ['crimes_masked']
โ photographs 0 file(s) with GPS tags: []
['counts.csv', 'crimes.gpkg', 'privacy_report.json', 'visit_01.jpg']
If the gate raises, nothing is published and the exception names the check. That is the whole design. The price here is visible in the report: the published counts table keeps only the combinations of five or more, which in this box is a small minority โ the decision to coarsen the cells or drop the category is now in front of a person, with the numbers.
Explanation
Why the gate must read the file back
Every serialisation step can undo a protection. GeoPandas writes full double precision by default, a GeoPackage can keep an earlier layer, an index column can carry the source row order, and a to_file on the wrong variable publishes the source. Reading the artefact back is the only way to test what you actually shipped โ the first Leicester release looked fine in memory, where the source layer was a separate variable.
Why the cross-product check matters more than the map check
A map of counts is one aggregation; a release is all of them. Checking only the aggregation you drew leaves every filter combination untested, and the filters are how users reach the data.
Why a failing gate should stop the build
A warning has a half-life. A gate that raises forces a decision by a person who can change the release โ raise the threshold, coarsen the geography, drop the offending records โ and records that decision in the commit that changed the parameter.
Why the report is part of the deliverable
Six months later, someone will ask what was done. A JSON file with the checks, the values, the auxiliary dataset version and the date answers that in a minute. How to record lineage automatically in a pipeline covers where it belongs among the rest of the provenance.
Edge cases or notes
- Pin the auxiliary dataset version. k changes when the address file updates; the report above records
osopenuprn_202609.csv. - Masking can lower k. A displacement that lands in a park leaves fewer candidates than the original street did; check the masked points, not the source.
- Check derived rasters too. A KDE at a 25 m bandwidth is a point file.
- Check the zip, not the folder. Packaging is where stale files get included.
- Exclude nothing "temporarily". A skipped check is a failed check.
- Run it in CI. The gate should fail a pull request, not a release.
- Keep a golden test. A tiny fixture that must fail every check proves the gate still works โ the naive Leicester release is one.
- Version the thresholds. Changing min_k is a decision, and belongs in the history.
- Do not log the failing coordinates. The report should name the check, not reproduce the data.
Internal links
- How to measure re-identification risk in a point dataset โ the measurements the gate wraps
- A shared file still contains the original coordinates โ the leaks the container checks catch
- How to strip GPS coordinates from photo metadata in Python โ the photograph check
- Aggregation and suppression rules explained โ the cross-product rule
- How to validate pipeline inputs and outputs in Python โ the same pattern for correctness
- How to test a GIS pipeline with pytest โ running the gate in CI
- How to record lineage automatically in a pipeline โ storing the report with the data
- Geoprivacy explained: why coordinates are personal data โ what the thresholds mean
FAQ
What should a privacy check test?
Uniqueness at the published precision, candidate counts against a named address file, the smallest cell across every filter combination, evidence that masking ran, direct identifiers in attributes, extra layers in the container, and GPS tags in attached photographs.
Should the check warn or fail?
Fail. A warning gets read once and then ignored, and the release still goes out.
Why read the file back from disk?
Because serialisation undoes protections: full-precision writes, retained layers, index columns carrying row order. The artefact is what ships, so the artefact is what must be tested.
How do I check that masking was applied?
Match published rows to the source by ID and measure how far each moved. Any row within a metre of its original is a row the mask missed, which happens whenever a join or a late correction reattaches the original geometry โ 40 rows in the first Leicester release.
Where should the gate run?
In CI, on the built artefact, before the publish step โ so a failure blocks a merge rather than a release.
What do I do with the report?
Ship it with the data. It records the parameters, the auxiliary dataset version and the date, which is what anyone reviewing the release later will ask for.