Anonymizing Location Data for GDPR

Part of: GDPR for Geospatial Data

Reducing the precision of coordinates is one of the most effective ways to bring geospatial processing within GDPR’s tolerance, but only if you can prove that the reduction actually lowered re-identification risk and only if the transformation itself is captured as lineage. Anonymizing silently — without a record of what was jittered, truncated, or generalized — trades one compliance problem for another, because you can no longer demonstrate that published data is no longer personal. This how-to anonymizes and pseudonymizes coordinates using three complementary techniques — geohash truncation, spatial k-anonymity, and differential-privacy jitter — while emitting a lineage record of the transformation. It applies the practices from the GDPR for geospatial data guide and links each transformation to the field-level crosswalk in the control-to-lineage-field mapping how-to.

The three techniques address different attack surfaces and are strongest when layered. Geohash truncation generalizes a precise point to a named cell, defeating exact-match lookups but leaving cell membership visible. Spatial k-anonymity guarantees that no released cell distinguishes fewer than k individuals, defeating uniqueness attacks but doing nothing about the accuracy of a surviving point. Differential-privacy jitter perturbs each coordinate with calibrated noise, defeating averaging and repeated-observation attacks but, on its own, leaving low-density outliers exposed. The layering also determines which regime the output satisfies: jitter alone produces pseudonymized data that remains personal under GDPR; jitter plus k-anonymity plus generalization, documented in lineage, is what lets you argue an extract is anonymized and therefore outside the regulation’s material scope.

Prerequisites

  • Python 3.10+ with geopandas 0.14+, pyproj 3.6+, numpy 1.26+, and pygeohash 1.2+
  • An input GeoDataFrame of points in a known CRS (this example assumes EPSG:4326 input and reprojects to a metric CRS for metre-accurate jitter)
  • A target k value agreed with your data protection officer for spatial k-anonymity (commonly k ≥ 5)
  • A lineage sink to receive the transformation event, per the append-only schema in the parent GDPR for geospatial data guide
The same grid, two densities A fixed aggregation cell produces high member counts in an urban area and counts of one in a rural area, which is why a fixed precision cannot be an anonymisation policy. One grid size, applied uniformly — counts per cell URBAN 312 287 154 401 all far above any sensible k — publish freely RURAL 1 0 2 1 a cell of 1 is that person's location, published Precision is not a policy. Threshold on the count, adapt the cell, and suppress what still fails.

This is the whole argument for testing counts rather than fixing precision, and it is why “we round to three decimal places” is never a sufficient answer. The urban row would survive far coarser rounding than it gets; the rural row is disclosing at any precision fine enough to be useful. A policy expressed as a coordinate precision optimises for neither and discloses in exactly the places where the individual is most identifiable.

Adaptive aggregation — growing the cell until the count clears k, then suppressing if it cannot — handles both rows with one rule and produces a dataset whose spatial resolution varies with density. That variation is itself informative and occasionally objected to, since the coarse cells reveal where the population is sparse. Where that matters, suppress rather than adapt and accept the gaps.

Implementation

The function below applies the three techniques and returns both the anonymized frame and a lineage event describing exactly what it did. Jitter is applied in a projected metric CRS so the noise magnitude is in metres, not degrees; k-anonymity suppresses points whose geohash cell holds fewer than k members; and geohash truncation generalizes location to a documented cell size.

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import hashlib
import json
import numpy as np
import geopandas as gpd
import pygeohash as pgh


@dataclass(frozen=True)
class AnonymizationEvent:
    method: str
    params: dict
    input_crs: str
    rows_in: int
    rows_out: int
    valid_from: datetime = field(
        default_factory=lambda: datetime.now(timezone.utc))

    def content_hash(self) -> str:
        body = json.dumps(
            {"method": self.method, "params": self.params,
             "input_crs": self.input_crs, "rows_in": self.rows_in,
             "rows_out": self.rows_out,
             "valid_from": self.valid_from.isoformat()},
            sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(body.encode()).hexdigest()


def anonymize_points(
    gdf: gpd.GeoDataFrame,
    *,
    geohash_precision: int = 6,      # 6 chars ≈ 1.2 km cell
    k: int = 5,                      # spatial k-anonymity threshold
    jitter_metres: float = 50.0,     # differential-privacy noise scale
    metric_crs: str = "EPSG:3857",
    seed: int = 0,
) -> tuple[gpd.GeoDataFrame, AnonymizationEvent]:
    """Generalize + jitter coordinates, returning data and a lineage event."""
    input_crs = gdf.crs.to_string()
    rows_in = len(gdf)

    # 1. Differential-privacy jitter in a metric CRS (Laplace noise, metres).
    rng = np.random.default_rng(seed)
    proj = gdf.to_crs(metric_crs).copy()
    scale = jitter_metres / np.sqrt(2)
    dx = rng.laplace(0.0, scale, size=rows_in)
    dy = rng.laplace(0.0, scale, size=rows_in)
    proj["geometry"] = proj.geometry.translate(xoff=dx, yoff=dy)
    jittered = proj.to_crs(input_crs)

    # 2. Geohash truncation to generalize location to a fixed cell.
    jittered["geohash"] = jittered.geometry.apply(
        lambda p: pgh.encode(p.y, p.x, precision=geohash_precision))

    # 3. Spatial k-anonymity: suppress cells with fewer than k members.
    counts = jittered["geohash"].value_counts()
    safe_cells = counts[counts >= k].index
    out = jittered[jittered["geohash"].isin(safe_cells)].copy()

    event = AnonymizationEvent(
        method="jitter+geohash+k_anonymity",
        params={"geohash_precision": geohash_precision, "k": k,
                "jitter_metres": jitter_metres, "metric_crs": metric_crs},
        input_crs=input_crs, rows_in=rows_in, rows_out=len(out))
    return out, event

Persist the returned event to your lineage store using the same append-only insert the parent guide describes; its content_hash anchors the transformation so an auditor can verify that the published extract is the documented, generalized version. Note the ordering: jitter is applied first, in the projected metric CRS, so that the generalization step then buckets already-perturbed points. Reversing the order — truncating first and jittering the cell centroid afterwards — would let an attacker who knows the cell grid subtract the deterministic snap and recover a tighter estimate of the original location. The event’s params block records every knob that shaped the output, which is what makes the guarantee reproducible: a reviewer can re-run the function with the recorded seed and parameters and obtain the identical extract, closing the gap between what you claim you published and what you can prove you published.

Why Suppression Beats Perturbation Here

Three techniques and what each can be shown to guarantee Suppression, jitter and differential privacy compared on bounded risk, auditability, and how each degrades the data. TECHNIQUE RISK BOUNDED? AUDITABLE? COST Suppress cells below k drop, do not publish yes — by k yes missing cells Coordinate jitter move each point randomly no weakly false precision Differential privacy calibrated noise, budget ε yes — by ε yes budget accounting

Jitter is the technique most often reached for and the only one of the three with no bounded guarantee. Moving each point by a random offset feels protective and leaves the data looking usable, but repeated releases of the same jittered population let the true positions be averaged out, and a single release still concentrates points around real locations in ways that spatial clustering recovers. Worse for compliance purposes, there is no parameter you can cite that expresses the residual risk — which makes the assessment unrecordable, and an unrecordable assessment is not a discharge.

Suppression is unglamorous and defensible: below the threshold nothing is published, so the residual risk is bounded by construction and the parameter that bounds it is a single integer you can log. Its cost — visible gaps in sparse areas — is a genuine analytical loss and an honest one. Differential privacy bounds risk more formally and costs an ε budget that must be tracked across releases, which is worth it where repeated publication of the same population is planned and overkill where it is not.

Verification

Confirm the transformation both reduced identifiability and recorded itself.

gdf = gpd.read_file("subject_points.gpkg").to_crs("EPSG:4326")
anon, event = anonymize_points(gdf, geohash_precision=6, k=5, jitter_metres=50)

# Every surviving cell must contain at least k members (k-anonymity holds).
assert (anon["geohash"].value_counts() >= 5).all()
# The lineage event is populated and hashable for the audit trail.
assert event.rows_out <= event.rows_in
print("suppressed rows:", event.rows_in - event.rows_out,
      "| event hash:", event.content_hash()[:12])

A passing run asserts that no surviving geohash cell holds fewer than k points and prints how many rows were suppressed alongside the event hash. Store the hash next to the published extract; matching it later proves the extract is the anonymized product and not the raw source.

Recording the Assessment, Not Just the Transformation

What an anonymisation lineage event must carry A minimal event records only that anonymisation happened; a defensible one records the parameters, what was suppressed, which auxiliary data was considered, and who assessed it. NOT ENOUGH activity: "anonymise" status: success DEFENSIBLE technique: k-anonymity · k: 50 aggregation_unit: census block cells_suppressed: 1,284 auxiliary_considered: [parcels, roll] assessed_by: role, dated a discharge you can point at The auxiliary-data field is the one nobody records and the one re-identification actually turns on.

Auxiliary data is what makes re-identification possible, so a record that does not say which auxiliary sources were considered cannot support a claim that the risk was assessed. In a spatial context the relevant sources are usually mundane and public: a parcel layer, an electoral roll, a business register. Naming them in the event turns “we anonymised it” into “we assessed identifiability against these specific joinable sources at this threshold on this date”, which is what an assessor is actually asking for.

The suppressed-cell count is the cheapest sanity check available. A count of zero across a heterogeneous study area almost always means the threshold test did not run rather than that every cell passed, and the number is trivially computed by the same code that performs the suppression. Log it, and alert when it is zero on a dataset that spans rural areas.

Gotchas & edge cases

  • Anonymization is not erasure, and jitter is not anonymity on its own. Low-count cells, outliers, and repeated observations of the same subject can re-identify even after jitter. Always pair jitter with the k-anonymity suppression above, and record k in the lineage event so the guarantee is auditable rather than assumed. This is the pitfall flagged in the regulatory compliance and standards mapping overview.
  • CRS-dependent jitter distorts distance. Applying Laplace noise in degrees rather than a metric CRS makes the effective jitter vary with latitude — 0.001 degree is far larger near the equator than near the poles. Reproject to a metric CRS before adding noise, and for large study areas prefer a local projected CRS over the web-mercator default, which distorts metre distances at high latitudes.
  • Geohash cell size is coarse and non-square. A precision-6 geohash is roughly 1.2 km by 0.6 km, not a tidy square, so “precision 6” does not mean a uniform radius of protection. Choose precision against the density of your data, and document the chosen cell in the lineage params so downstream users understand the generalization applied.