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

The seven checks a spatial privacy gate runs on a built release. the gateuniqueness at published precisioncandidate k against a named address filesmallest cell across every filtermasking actually applied to every rowno identifier-like attributesno extra layers in the containerno GPS tags in attached photographsSeven checks, one exception; a warning will be ignored spatialworkflow.io
Seven checks, one exception; anything that only warns will eventually be ignored.

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.

Where the privacy gate sits between building the release and publishing it. the only path outsourcetrue pointsmask +aggregatethe treatmentwrite filesthe artefactread back +gateraises on failurepublishplus the reportThe gate reads the built files, never the in-memory frame spatialworkflow.io
The gate sits between writing the files and publishing them, and it is the only path out.

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.

Share of non-empty counts below five in each cross-tabulation of the masked central-Leicester crimes, by 250 m cell. central Leicester, Q1 2025: counts below 5cell24 of 117cell x month114 of 314cell x category632 of 865cell x month x category1,417 of 1,608050%100%Every dimension added makes more of the table disclosive spatialworkflow.io
Measured in Example 2 on the 4,013 donut-masked crimes. The cell totals already have small counts at the edges of the centre; the table a user can filter is mostly 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.

Five things a privacy gate catches that a code review does not. what the gate catcheswhat shippedwhich check finds itsource layer still in theGeoPackagelayer list on the built filea filter applied after maskinggeometry equality against sourcecategory table below the thresholdsmallest cell across filtersphotographs with GPS tagsEXIF scan of the delivery folderpostcode left in a text columnattribute and value regexEvery one of these has shipped in a real release spatialworkflow.io
None of these is visible in a diff; all of them are visible in the artefact.

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.

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.