Redacting Spatial PII in Test Fixtures
A coordinate is quasi-identifying in a way few other attributes are: a handful of visited points is close to unique for an individual, and a single residential position is effectively an identifier on its own. This guide sits beneath security boundaries in spatial QA and covers the practical mechanics of redacting a spatial fixture: choosing the weakest treatment that still lets the test observe what it asserts, handling the attributes that leak as readily as the geometry, and doing the work at capture time so the raw data never reaches a repository.
The conclusion most teams arrive at eventually is worth stating first: for the majority of spatial tests, the correct treatment is not redaction at all but synthesis. Most assertions read structure — validity, type, cardinality, CRS — and structure is exactly what a generator reproduces perfectly.
Root cause: the test rarely reads what makes the data sensitive
A fixture derived from real movement or address data carries positional precision that no assertion in the suite examines. A topology check reads adjacency; a schema check reads columns; a parity check reads a round trip. None of them is sensitive to whether a vertex is at its true position or fifty metres away, and all of them are sensitive to whether the geometry is well-formed.
That mismatch is the whole opportunity. The detail that makes the fixture risky is orthogonal to the detail the tests consume, so it can be removed without weakening anything — provided the removal is chosen against what the assertions actually read rather than against a general sense of caution.
Treatment reference
| Treatment | Preserves | Destroys | Use when the test asserts |
|---|---|---|---|
| Truncate coordinate precision | Rough position, coarse topology | Sub-metre position | Format, schema, CRS handling |
| Snap to a grid cell | Cell membership, per-cell counts | Position within a cell | Aggregation and binning |
| Jitter within a radius | Statistical distribution | Individual position | Density, heat-map behaviour |
| Replace with synthetic geometry | Structure and volume only | All real position | Everything else — the default |
| Aggregate to an administrative area | Area-level totals | Point-level anything | Reporting roll-ups |
The fourth row is the default because it is the one that removes the obligation entirely rather than reducing it. A synthesised fixture is not personal data at all, so it needs no retention policy, no access control, and no answer to a deletion request.
Step-by-step implementation
The treatments target GeoPandas 0.14+ and Shapely 2.x, and run in the job that produces the fixture.
Step 1 — Decide the treatment from the assertion, not from caution
Write the decision down next to the fixture, because a future reader cannot infer it and will otherwise assume the strongest.
# fixtures/spec.py — one line per fixture, reviewed like code
TREATMENTS = {
"parcels_topology": "synthetic", # asserts adjacency only
"density_grid": "jitter:50m", # asserts distribution shape
"region_totals": "aggregate:lsoa", # asserts roll-up arithmetic
"format_roundtrip": "synthetic", # asserts driver behaviour
}
Step 2 — Redact the attributes, not only the geometry
Jittering a point while leaving a household identifier, a timestamp sequence, or a rare categorical value attached re-identifies just as effectively as the coordinate did.
import pandas as pd
QUASI_IDENTIFIERS = ["household_id", "visit_time", "occupation"]
def redact_attributes(gdf, k: int = 5):
out = gdf.drop(columns=[c for c in QUASI_IDENTIFIERS if c in gdf.columns])
# Suppress categories with fewer than k members — a rare value is an identifier.
for col in out.select_dtypes(include="object").columns:
counts = out[col].value_counts()
rare = counts[counts < k].index
out.loc[out[col].isin(rare), col] = "OTHER"
return out
Step 3 — Apply the geometric treatment
Jitter must be drawn from a seeded generator so the fixture is reproducible, and the radius must exceed the precision an adversary could exploit.
import numpy as np
import shapely
def jitter(gdf, radius_m: float, seed: int):
rng = np.random.default_rng(seed)
n = len(gdf)
angle = rng.uniform(0, 2 * np.pi, n)
dist = radius_m * np.sqrt(rng.uniform(0, 1, n)) # uniform over the disc
dx, dy = dist * np.cos(angle), dist * np.sin(angle)
out = gdf.copy()
out["geometry"] = shapely.transform(
gdf.geometry.values, lambda c, i=iter(zip(dx, dy)): c # applied per feature
)
return out
Step 4 — Do it at capture, and never write the raw data
This is the step that decides whether the whole exercise works. A redaction applied when the fixture is read leaves the raw data in the repository, in every clone, and in the CI cache.
def build_fixture(source_query: str, out_path: str, seed: int):
raw = read_from_secure_store(source_query) # in memory only
safe = jitter(redact_attributes(raw), radius_m=50, seed=seed)
safe.to_file(out_path) # only the treated data is written
del raw # never persisted, never logged
Verify the fix
Assert that the treated fixture no longer carries what it should not:
pytest -q tests/fixtures/test_redaction.py -v
The checks worth having are mechanical: the quasi-identifier columns are absent, no categorical value has fewer than k members, and every geometry differs from its source by at least the jitter radius — the last of which requires the source, so it runs in the producing job rather than in the suite.
Why the Combination Matters More Than Any Field
Re-identification rarely depends on one attribute. It depends on the combination of several that are individually unremarkable, and that is why a field-by-field review consistently under-estimates the risk of a spatial fixture.
A position accurate to a hundred metres is not identifying on its own in a city. A timestamp to the nearest hour is not identifying. An occupation is not identifying. The three together, for one person, frequently are — and the geometry contributes far more to that combination than a non-spatial dataset’s fields would, because position correlates with almost everything else about a person.
The practical consequence is that redaction has to be assessed against the whole record rather than field by field. Two rules make that tractable without a formal privacy analysis.
Apply a minimum-count rule to combinations, not just to columns. If fewer than k records share a given combination of the coarsened position, the time bucket and the categorical fields, that combination is identifying regardless of how ordinary each part looks. Suppressing or generalising until every combination reaches k is the standard construction, and it is cheap to compute on a fixture-sized dataset.
Reduce the number of quasi-identifying fields before reducing their precision. Dropping a column removes its contribution to every combination at once, whereas coarsening it only weakens that contribution. For a test fixture the columns are usually droppable, because the assertions do not read them — which returns to the same observation the whole guide rests on.
Failure modes and edge cases
- Redacting geometry and leaving attributes. A jittered point with a household identifier attached is not redacted. The attribute pass is the half that gets forgotten.
- Jitter smaller than the precision it hides. Moving a residential point by two metres does not prevent re-identification. The radius must exceed the resolution at which the position is meaningful.
- A rare category acting as an identifier. One person with an unusual occupation in a small area is identified by the combination, not by either field. Suppress categories below a threshold.
- Unseeded jitter. A fixture that differs on every generation is not reproducible, so a failing test cannot be re-run against the same data. Seed it and record the seed.
- Redacting at read time. The raw data reaches the repository, the clones and the cache. The treatment must run where the data is produced.
- Assuming aggregation is always safe. An administrative area with very few members leaks nearly as much as a point. Apply a minimum-count rule to areas as well as to categories.
Reviewing a redaction before it ships
A redaction is a claim, and like any other claim it benefits from being checked by somebody who did not make it. Three questions make that review quick rather than open-ended.
What does the test actually read? If the reviewer cannot answer from the assertions, the treatment was chosen against a general sense of caution rather than against a requirement — which usually means it is simultaneously too strong for the test and too weak for the data.
Could the source be reconstructed? Jitter that is applied to each feature independently can be partially reversed when the same underlying position appears in several records, because averaging the copies recovers the original. Applying a consistent offset per subject rather than per record removes that, at the cost of preserving relative structure that may itself be identifying.
Where is the raw data now? The answer should be “in the secure store and nowhere else”. A reviewer who finds a working copy in a developer’s directory has found the actual risk, and no amount of treatment applied downstream addresses it.
The review is worth attaching to the fixture rather than to a ticket, because the fixture will outlive both. A short note recording who reviewed the treatment, when, and against which assertions is what allows a future reader to trust it without repeating the analysis — and it is the same discipline that makes a provenance record worth keeping.
Conclusion
Spatial redaction works when it is chosen against what the assertions read rather than applied uniformly, when the attributes are treated alongside the geometry, and when it happens in the job that produces the fixture rather than the one that consumes it. For most spatial tests the strongest and simplest treatment is to generate rather than derive, which removes the obligation entirely — the outcome security boundaries in spatial QA points at from the other direction.
Related
- Security Boundaries in Spatial QA — the parent layer and the redaction ladder this guide implements.
- Audit Trail Schemas for Coordinate-Level Access Logs — recording who read what, when redaction is not enough.
- Synthetic Vector Data Generation — the generation-first alternative that avoids the obligation.
- Fixture Versioning and Provenance — recording the derivation so the obligation travels with the fixture.