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.

Checklist of the seven privacy checks with pass and fail marks.
Seven checks, one exception; anything that only warns will eventually be ignored.

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.

Flow from source through masking and aggregation to the gate, with a failing branch back to the pipeline.
The gate sits between writing the files and publishing them, and it is the only path out.

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.

Triage of five release failures โ€” a retained source layer, a filter applied after masking, an under-threshold category table, photographs with GPS tags and a postcode column โ€” with the gate check that finds each.
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.
  • 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.

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.