How to run a privacy check before publishing a spatial dataset
Problem statement
Every failure in this cluster is a step somebody meant to do and did not: the mask that was not applied to the second file, 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 wires it into a pipeline.
Quick answer
def privacy_gate(release, *, candidates, min_k=5, min_cell=5, quasi=()):
"""Raise unless every check passes. Returns the report when it does."""
checks = [
check_uniqueness(release, quasi),
check_candidate_k(release, candidates, min_k),
check_min_cell(release, quasi, min_cell),
check_masking_applied(release),
check_attributes(release),
check_hidden_layers(release),
check_photographs(release),
]
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.
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.
2. Check uniqueness at the published precision
Count how many records are alone on their coordinate after rounding, masking or aggregation. On an unmasked address extract that number is the row count; anything close to it should fail.
3. Check candidate counts against a named auxiliary file
Report the minimum k and the number of records below the threshold. Pin the auxiliary file's version in the configuration so the number is reproducible.
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 London crime table was safe as a map and had 643 of 1,228 non-empty cells below five once crossed with category.
5. Check that the masking actually ran
Compare the published geometry with the source. If any coordinate is identical, the mask was skipped for that row โ which is exactly what happens when a filter is applied after masking, or a join brings the original geometry back.
same = published.geometry.geom_equals_exact(source.geometry, tolerance=1e-6)
assert not same.any(), f"{same.sum()} rows kept their original geometry"
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.
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.
import fiona
layers = fiona.listlayers("release.gpkg")
assert layers == ["cases_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
Example 1 โ the individual checks
import numpy as np, pandas as pd, geopandas as gpd, fiona, pathlib, piexif, re
from scipy.spatial import cKDTree
IDENTIFIERS = re.compile(r"postcode|zip|uprn|address|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):
key = pd.Series(list(zip(gdf.geometry.x.round(1), gdf.geometry.y.round(1),
*[gdf[c] for c in quasi])))
vc = key.value_counts()
alone = int((vc == 1).sum())
return {"name": "uniqueness", "pass": alone / len(gdf) < 0.5,
"detail": f"{alone:,} of {len(gdf):,} records alone in their cell"}
def check_candidate_k(gdf, candidates, min_k, radius=100):
tree = cKDTree(np.c_[candidates.geometry.x, candidates.geometry.y])
k = np.array([len(tree.query_ball_point(p, radius))
for p in np.c_[gdf.geometry.x, gdf.geometry.y]])
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_attributes(gdf):
bad = [c for c in gdf.columns if IDENTIFIERS.search(c)]
text = [c for c in gdf.columns if gdf[c].dtype == object
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 = fiona.listlayers(path)
return {"name": "layers", "pass": sorted(layers) == sorted(expected),
"detail": f"found {layers}, expected {expected}"}
def check_photographs(folder):
leaks = [p.name for p in pathlib.Path(folder).glob("**/*.jpg")
if len(piexif.load(str(p))["GPS"])]
return {"name": "photographs", "pass": not leaks,
"detail": f"{len(leaks)} file(s) with GPS tags: {leaks[:5]}"}
Example 2 โ the minimum published cell across the cross-product
import itertools, pandas as pd
def check_min_cell(gdf, dimensions, min_cell):
cell = pd.Series(list(zip(gdf.geometry.x // 250, gdf.geometry.y // 250)))
worst = {"cell": None, "n": np.inf}
for r in range(1, len(dimensions) + 1):
for combo in itertools.combinations(dimensions, r):
counts = gdf.groupby([cell, *[gdf[c] for c in combo]]).size()
if counts.min() < worst["n"]:
worst = {"cell": ("geometry", *combo), "n": int(counts.min())}
return {"name": "min cell", "pass": worst["n"] >= min_cell,
"detail": f"smallest cell {worst['n']} at {worst['cell']}"}
Checking every combination is what catches the case where cell ร category is safe and cell ร category ร month is not.
Example 3 โ wiring it into a pipeline
import json, datetime, pathlib
def publish(masked, source, candidates, out="release.gpkg", photos="photos/"):
masked.to_file(out, layer="cases_masked", driver="GPKG")
written = gpd.read_file(out, layer="cases_masked") # read back what shipped
report = privacy_gate(written, candidates=candidates, min_k=5, min_cell=5,
quasi=("month", "category"))
report.append(check_hidden_layers(out, ["cases_masked"]))
report.append(check_photographs(photos))
pathlib.Path("privacy_report.json").write_text(json.dumps({
"created": datetime.datetime.now().isoformat(timespec="seconds"),
"auxiliary": "OS Open UPRN 2026-04",
"checks": report,
}, indent=2))
return out
If the gate raises, nothing is published and the exception names the check. That is the whole design.
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.
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.
- 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.
- 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?
Compare published geometries with the source. Any exact match is a row the mask missed, which happens whenever a join reattaches the original geometry.
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.