Redacting Sensitive Locations Before Publication

Derive every displacement deterministically from a stable per-feature key, so re-running the pipeline moves each point to the same place it moved to last time — a random jitter re-applied on each release lets a recipient average several releases back to the true location, which is the opposite of what the redaction was for.

Redaction is where good intentions produce the most self-defeating implementations. Jittering points sounds like generalisation and is not; suppressing a category leaves its absence visible; masking a boundary at one resolution while publishing a derived layer at another gives both away. This guide sits under regulatory obligations for location data, within Spatial Data Audit Reporting & Compliance Governance.

Four redaction techniques and what each preservesRounding, deterministic displacement, aggregation and suppression compared by what analysis they preserve and how they fail.PreservesFails whenRounding to a griddensity, containment at coarsescalessparse areas stay uniqueDeterministic displacementlocal density, approximatepositionre-run with a new seedAggregation to zonescounts, comparabilitysmall cells, differencingSuppressioneverything that remainsbiases against sparse areas
Rounding is the honest default. The others are for cases where it is not enough.

Automated Python Implementation

#!/usr/bin/env python3
"""Deterministic, reproducible redaction for point layers."""
import hashlib
import math

import geopandas as gpd
from shapely.geometry import Point

def _unit_offsets(key, secret):
    """Two stable values in [0, 1) derived from a feature key and a held secret."""
    digest = hashlib.sha256(f"{secret}:{key}".encode()).digest()
    a = int.from_bytes(digest[:8], "big") / 2 ** 64
    b = int.from_bytes(digest[8:16], "big") / 2 ** 64
    return a, b

def displace_point(point, key, secret, max_radius_m):
    """Move a point to a fixed pseudo-random position within max_radius_m.

    Uniform over the disc — sqrt on the radius, or points bunch at the centre
    and the displacement is weaker than the stated radius implies.
    """
    a, b = _unit_offsets(key, secret)
    radius = max_radius_m * math.sqrt(a)
    angle = 2 * math.pi * b
    return Point(point.x + radius * math.cos(angle),
                 point.y + radius * math.sin(angle))

def round_to_grid(point, cell_m):
    """Snap to a grid cell centre; the simplest defensible generalisation."""
    return Point(math.floor(point.x / cell_m) * cell_m + cell_m / 2,
                 math.floor(point.y / cell_m) * cell_m + cell_m / 2)

def redact(gdf, key_column, secret, method="round", cell_m=100, max_radius_m=100,
           metric_crs="EPSG:3857"):
    """Apply a redaction and record exactly what was done."""
    if gdf.crs is None:
        raise ValueError("layer has no CRS; metre-based redaction is undefined")
    original_crs = gdf.crs
    working = gdf.to_crs(metric_crs).copy()

    if method == "round":
        working["geometry"] = working.geometry.apply(lambda g: round_to_grid(g, cell_m))
        parameters = {"method": "round", "cell_m": cell_m}
    elif method == "displace":
        working["geometry"] = [
            displace_point(geom, key, secret, max_radius_m)
            for geom, key in zip(working.geometry, working[key_column])
        ]
        parameters = {"method": "displace", "max_radius_m": max_radius_m}
    else:
        raise ValueError(f"unknown redaction method {method!r}")

    result = working.to_crs(original_crs)
    result.attrs["redaction"] = parameters
    return result, parameters

Three details in that code are the ones that separate a redaction from a gesture.

The offsets come from a hash of a stable key and a held secret. The key makes the displacement reproducible across runs; the secret stops a recipient who knows the algorithm from inverting it. Both are necessary — a hash of the key alone is reversible by anyone with the key list, and a random offset is not reproducible at all.

The radius uses a square root. Sampling radius uniformly puts most points near the centre, so a stated 100 metre displacement delivers a median of about 33 metres. Uniform-over-the-disc sampling delivers what it says.

The method and parameters travel with the output. A redacted layer whose displacement radius is not recorded cannot be interpreted by an analyst or defended by its publisher, and the parameters are exactly what a reviewer asks for.

Why the key must be stable and the secret heldA stable feature key plus a held secret produce a displacement that is identical across releases and not invertible by a recipient.Feature keystable across releaseswithHeld secretnot publishedhashFixed offsetsame every runapplyRedacted pointreproducible, not invertible
Change either input and the point moves; average two releases and the true position appears.

Validation and Pipeline Integration

def test_displacement_is_reproducible():
    point = Point(0, 0)
    first = displace_point(point, "feature-1", "secret", 100)
    second = displace_point(point, "feature-1", "secret", 100)
    assert first.equals(second)

def test_different_features_move_differently():
    point = Point(0, 0)
    a = displace_point(point, "feature-1", "secret", 100)
    b = displace_point(point, "feature-2", "secret", 100)
    assert not a.equals(b)

def test_displacement_stays_within_the_radius():
    point = Point(0, 0)
    for i in range(500):
        moved = displace_point(point, f"f{i}", "secret", 100)
        assert moved.distance(point) <= 100.0001

def test_redaction_parameters_are_recorded():
    gdf = gpd.GeoDataFrame({"id": ["a"]}, geometry=[Point(0, 0)], crs="EPSG:4326")
    _, parameters = redact(gdf, "id", "secret", method="round", cell_m=100)
    assert parameters == {"method": "round", "cell_m": 100}

Run the redaction as the last step before export, after every join and derivation, and re-measure re-identification risk on the redacted output rather than assuming the redaction worked. A displacement that leaves a rural point isolated has changed its coordinates and not its identifiability, which the measurement in assessing re-identification risk in point datasets will show immediately.

What Redaction Cannot Fix

Some disclosure risks are structural, and applying a stronger displacement to them makes the data worse without making it safer.

Disclosure paths that survive a geometric redactionAttribute uniqueness, unredacted derived products and repeated releases each defeat displacement without touching the redacted geometry.Redacted point layergeometry moved, and yetAttribute uniquenessunaffected by geometryRare categoryone member, identifiableFixgeneralise the attributeDerived productsbuilt before redactionService areasconstrain the true pointsFixrebuild after redactionRepeated releasesconsistency has a costSame key each timeyields a trajectoryFixdesign the schedule
Three paths around the redaction, none of which a stronger displacement closes.

Attribute uniqueness survives any geometric change. A record that is the only one of its category in the dataset is identifiable from the attribute alone once anyone knows a single member. Moving its point does nothing. The response is attribute generalisation — collapsing rare categories — or suppression, and it belongs to a different toolbox than displacement.

Relationships between layers leak. Publishing a redacted point layer alongside an unredacted layer derived from the same source is the common version of this: the derived layer’s geometry, counts or extents constrain where the redacted points must be. The fix is to treat all products of one source as a single release and redact them consistently, which requires knowing what those products are — a lineage question.

Repeated releases of a moving population reveal trajectories. A quarterly release of redacted current locations, each displaced consistently, gives a recipient a sequence of positions for the same feature key. Consistency is what makes displacement defensible within a release and what creates a trajectory across them, and the tension is real rather than a mistake in the implementation. Where a population moves, the release schedule is part of the disclosure design, not an operational detail.

Boundaries give away what points do not. A masked point layer published with a service-area polygon computed from the unmasked points hands back the original geometry at analytical precision. Any derived product built before redaction has to be either rebuilt afterwards or withheld.

Long-Term Compliance Best Practices

  • Never re-seed between releases. A fresh random offset each time lets averaging recover the true position.
  • Keep the secret out of the repository. It belongs with deployment secrets; publishing it makes the displacement invertible.
  • Record the method and parameters with the output. They are what an analyst needs and what a reviewer will ask for.
  • Redact last, then re-measure. Redaction applied before a join can be undone by the join.
  • Prefer rounding when it suffices. It is easier to explain, easier to reason about, and has no secret to protect.