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.

What the fixture carries and what the tests read Two lists set side by side. The fixture carries exact positions, precise timestamps, rare attribute values, household or person identifiers, and the structural properties of the geometry. The assertions read only the structural properties: whether the geometry is valid, its type, the coordinate reference system, the attribute schema, and feature counts. A small overlap is marked between the two lists, covering only the structural properties. Everything outside the overlap — the positions, timestamps, rare values and identifiers — can be removed without weakening a single assertion. A closing note records that the detail making the fixture risky and the detail the tests consume are almost entirely disjoint. What the fixture carries exact positionsprecise timestamps rare attribute valueshousehold identifiers the overlap geometry structuretype and CRS attribute schemafeature counts What the assertions read validity, adjacency, cardinality round-trip fidelity schema conformance counts and totals none of it needs a true position The detail that makes the fixture risky and the detail the tests consume are almost disjoint. Everything in the left column can go without weakening a single assertion. Which is why synthesis, rather than redaction, is usually the right answer.

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
Redact at capture, not at read Two pipelines. In the late arrangement, the raw extract is written to a working directory, committed to the repository, cloned by every developer and cached by continuous integration, and only then does a redaction step run at read time; four copies of the untreated data are marked as existing outside the redaction's reach. In the early arrangement, the producing job reads the raw data into memory, applies the attribute and geometric treatments there, and writes only the treated result, so no untreated copy exists anywhere downstream and the redaction cannot be bypassed by reading the file directly. Redacting at read — four untreated copies already exist raw extract working dirrepositoryevery cloneCI cache redact the treatment runs after the data has been copied four times Redacting at capture — no untreated copy is ever written raw, in memory redact treated fixture → working dir, repository, clones, CI cache every downstream copy is already safe A redaction that runs at read time can be bypassed by reading the file directly, which is what every other tool in the pipeline does. The treatment belongs in the job that produces the fixture, and the raw input must never be persisted.

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.

Three harmless fields, one identifying combination Three fields shown with the population each matches on its own. A position coarsened to a hundred metres matches many thousands of people in an urban area. A timestamp rounded to the hour matches many thousands. A recorded occupation matches many thousands. Their intersection is shown matching a single person, which is why reviewing each field in isolation consistently under-estimates the risk of a spatial fixture. Two remedies are given: apply a minimum-count rule to the combination rather than to any single column, and prefer dropping a quasi-identifying column outright over coarsening it, since dropping removes its contribution to every combination at once. position ± 100 m matches many thousands timestamp to the hour matches many thousands occupation matches many thousands the intersection matches one min-count rule on the combination generalise until every combination reaches k drop the column, do not coarsen it removes its contribution to every combination For a test fixture the columns are usually droppable, because no assertion reads them — which is where this guide started.

Failure modes and edge cases

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Redacting at read time. The raw data reaches the repository, the clones and the cache. The treatment must run where the data is produced.
  6. 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.