GDPR Article 25 Spatial Controls
Article 25 of the GDPR is the only clause in the regulation that speaks directly to engineers, and it is routinely mistranslated into a policy PDF instead of a config file. Its two limbs — data protection by design and by default — become enforceable in a spatial pipeline only when each is compiled down to three numbers a release either satisfies or fails: a minimum grid-cell size, a cumulative per-subject ceiling, and a retention window in days. This page, part of the compliance framework mapping guide inside the broader Core Fundamentals & Architecture for Spatial Privacy reference, shows how to turn Article 25(1) and 25(2) into a machine-checkable admission control that rejects any location release which is finer, hungrier, or longer-lived than the declared purpose can justify. Everything below resolves to a cell edge in metres, an cap, or a retention day count — never a compliance adjective.
From Two Legal Limbs to Three Parameters
Article 25(1) — by design — requires that data-protection measures be built into processing “at the time of the determination of the means,” i.e. baked into ingestion rather than bolted on at query time. In spatial terms that is a set of hard minimums the system enforces before a coordinate is ever stored: a coarsest-allowed truncation resolution, a bounded cumulative that the privacy budget management accountant will not let a subject exceed, and a retention ceiling after which the record is erased.
Article 25(2) — by default — is the stricter and more often-violated limb: without any human intervention, the system must apply the most privacy-protective setting. For location data this means precise geolocation is off unless a documented justification switches it on, and the default resolution is the coarsest cell that still serves the purpose — a direct expression of the data-minimisation and purpose-limitation principles of Article 5(1)(b)–©. Resolution is matched to purpose, not to what the sensor happens to emit.
The third strand is accountability (Article 5(2), reinforced by Article 35’s DPIA obligation): every parameter the two limbs fix must be documented and reproducible. In practice that means each release carries a reference to the DPIA record that set its cell size, budget cap, and retention window, so an auditor can trace any published tile back to the decision that authorised its precision. The mapping from limb to control to parameter is the whole engineering job, and it is summarised in the diagram below.
Key concept. Article 25 is not satisfied by a control that can be configured privately — it is satisfied only when the private configuration is the one that ships by default. In a spatial pipeline the default must be the coarsest cell size, the tightest budget, and the shortest retention the declared purpose can tolerate; anything finer is a deliberate, justified, logged exception.
Parameter Configuration and Calibration
An Article 25 admission control is parameterised per declared purpose. Each purpose owns a profile of four numbers, fixed once in the DPIA and thereafter immutable at runtime. Two of them encode resolution: min_cell_m is the coarsest cell that still serves the purpose (the by default target), and hard_floor_m is the finest resolution ever permitted even with a documented justification (the by design absolute limit). The other two are the cumulative ceiling per data subject and the maximum retention in days.
Resolution is expressed in metres of cell edge rather than decimal degrees so it is projection-independent and legible to a reviewer; at the equator a 500 m cell is roughly , and truncating WGS84 coordinates to 3 decimal places (~110 m) is finer than most analytics purposes can justify. The cap is the cumulative spend an individual may accrue across all releases, not a per-query value — it is the quantity the accountant tracks and the one that ties this control to privacy budget management.
| Declared purpose | min_cell_m (default) |
hard_floor_m |
epsilon_cap |
max_retention_days |
|---|---|---|---|---|
| Traffic-flow analytics | 500 | 250 | 1.0 | 30 |
| Public-health surveillance | 1000 | 500 | 0.5 | 90 |
| Fleet routing (ops) | 250 | 100 | 2.0 | 7 |
| Urban-planning aggregates | 1000 | 1000 | 0.5 | 365 |
The calibration logic behind each row is deliberate. A tighter purpose (public-health surveillance over a whole population) gets a coarser default and a smaller because the population is large and individual dwell points are highly identifying — the same reasoning the spatial sensitivity scoring models formalise as a risk tier. An operational purpose like live fleet routing legitimately needs finer geometry, so its floor drops to 100 m, but it pays for that precision with a 7-day retention ceiling: minimisation is preserved on the time axis when it must be relaxed on the space axis. Note the urban-planning row where min_cell_m == hard_floor_m: there is no justified path to finer data at all, so the by design and by default limits coincide.
Reference Implementation
The class below is the admission control itself: a type-annotated Article25Policy that, given a ReleaseRequest, asserts the request satisfies the by design and by default minimums for its declared purpose and rejects it otherwise. Each check is commented with the limb it enforces. It is pure-stdlib and deterministic, so it belongs in the ingestion path and in CI, gating every release before a single coordinate is published.
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict
@dataclass(frozen=True)
class PurposeProfile:
"""Article 25 minimums for one declared processing purpose.
Every field is a concrete engineering parameter, not a legal phrase:
the DPIA fixes these once, and the validator enforces them per release.
"""
min_cell_m: float # coarsest cell that still serves the purpose (by default)
hard_floor_m: float # finest resolution EVER allowed, even justified (by design)
epsilon_cap: float # cumulative per-subject epsilon ceiling (by design)
max_retention_days: int # erase-by window on the time axis (by design)
@dataclass(frozen=True)
class ReleaseRequest:
"""A proposed spatial release, in the units the policy reasons about."""
purpose: str
resolution_m: float # requested cell edge / truncation resolution, metres
epsilon: float # cumulative epsilon this release brings the subject to
retention_days: int
dpia_ref: str = "" # accountability: identifier of the DPIA record
precise_geo_justified: bool = False # by DEFAULT precise geo is OFF (False)
class Article25Violation(ValueError):
"""Raised when a requested release breaches a limb of Article 25."""
class Article25Policy:
"""Compile GDPR Art. 25(1)/(2) into an admission control for spatial releases.
A release is admitted only if it is no finer, no more budget-hungry, and no
longer-lived than the declared purpose justifies, and only if it is traceable
to a DPIA record. Larger resolution_m == coarser == more private.
"""
def __init__(self, profiles: Dict[str, PurposeProfile]) -> None:
self._profiles = profiles
def validate(self, req: ReleaseRequest) -> bool:
"""Return True if the release is admissible, else raise Article25Violation."""
try:
profile = self._profiles[req.purpose]
except KeyError:
# No declared purpose => no lawful basis to size the controls.
raise Article25Violation(f"undeclared purpose: {req.purpose!r}")
# --- Art. 25(1) by design: hard minimums baked into ingestion ---
# A resolution below the absolute floor is never lawful, justified or not.
if req.resolution_m < profile.hard_floor_m:
raise Article25Violation(
f"by-design: {req.resolution_m} m finer than hard floor "
f"{profile.hard_floor_m} m for {req.purpose!r}"
)
# Cumulative epsilon is minimisation on the information axis.
if req.epsilon > profile.epsilon_cap:
raise Article25Violation(
f"by-design: cumulative ε {req.epsilon} exceeds cap "
f"{profile.epsilon_cap} for {req.purpose!r}"
)
# Storage limitation (Art. 5(1)(e)) as a day count.
if req.retention_days > profile.max_retention_days:
raise Article25Violation(
f"by-design: retention {req.retention_days} d exceeds max "
f"{profile.max_retention_days} d for {req.purpose!r}"
)
# --- Art. 25(2) by default: strictest setting unless justified ---
# The default is the coarsest cell serving the purpose. Anything finer
# (precise geo) is an exception that MUST carry an explicit justification.
if req.resolution_m < profile.min_cell_m and not req.precise_geo_justified:
raise Article25Violation(
f"by-default: {req.resolution_m} m below default {profile.min_cell_m} m "
f"and precise_geo_justified is False for {req.purpose!r}"
)
# --- Art. 5(2) accountability: the parameters must be documented ---
if not req.dpia_ref.strip():
raise Article25Violation("accountability: release lacks a DPIA reference")
return True
The design keeps resolution_m monotone — larger is coarser and more private — so every comparison reads in the direction of the guarantee: a release is rejected when it is smaller (finer) than a floor. The precise_geo_justified flag defaults to False, which is the whole point of the by default limb encoded in a dataclass: absent a positive, deliberate justification, the strict path is taken automatically.
Validation Checkpoint
The invariants that matter are that an over-precise request is rejected, an over-budget request is rejected, and a fully compliant request passes — and that the two limbs fail independently, so a reviewer can see which one a release breached. The harness below asserts all three and is safe to run in CI.
def _validate() -> None:
policy = Article25Policy({
"traffic_flow": PurposeProfile(
min_cell_m=500.0, hard_floor_m=250.0, epsilon_cap=1.0, max_retention_days=30
),
})
# 1. Compliant release at the default cell size passes.
ok = ReleaseRequest(
purpose="traffic_flow", resolution_m=500.0, epsilon=0.8,
retention_days=30, dpia_ref="DPIA-2026-014",
)
assert policy.validate(ok) is True
# 2. Over-precise BY DEFAULT: finer than 500 m default, no justification -> reject.
over_precise = ReleaseRequest(
purpose="traffic_flow", resolution_m=300.0, epsilon=0.8,
retention_days=30, dpia_ref="DPIA-2026-014",
)
try:
policy.validate(over_precise)
except Article25Violation as exc:
assert "by-default" in str(exc)
else:
raise AssertionError("finer-than-default release must be rejected")
# 3. Same 300 m precision becomes lawful WITH a documented justification,
# because 300 m still sits above the 250 m hard floor.
justified = ReleaseRequest(
purpose="traffic_flow", resolution_m=300.0, epsilon=0.8, retention_days=30,
dpia_ref="DPIA-2026-014", precise_geo_justified=True,
)
assert policy.validate(justified) is True
# 4. Over-budget BY DESIGN: cumulative epsilon above the cap -> reject.
over_budget = ReleaseRequest(
purpose="traffic_flow", resolution_m=500.0, epsilon=1.4,
retention_days=30, dpia_ref="DPIA-2026-014",
)
try:
policy.validate(over_budget)
except Article25Violation as exc:
assert "by-design" in str(exc)
else:
raise AssertionError("over-budget release must be rejected")
# 5. Even a justified request cannot cross the hard floor (150 m < 250 m).
below_floor = ReleaseRequest(
purpose="traffic_flow", resolution_m=150.0, epsilon=0.8, retention_days=30,
dpia_ref="DPIA-2026-014", precise_geo_justified=True,
)
try:
policy.validate(below_floor)
except Article25Violation as exc:
assert "hard floor" in str(exc)
else:
raise AssertionError("sub-floor release must be rejected even when justified")
# 6. Accountability: a compliant release with no DPIA reference still fails.
undocumented = ReleaseRequest(
purpose="traffic_flow", resolution_m=500.0, epsilon=0.8,
retention_days=30, dpia_ref="",
)
try:
policy.validate(undocumented)
except Article25Violation as exc:
assert "DPIA" in str(exc)
else:
raise AssertionError("undocumented release must be rejected")
print("all Article 25 admission-control invariants hold")
if __name__ == "__main__":
_validate()
Assertion 3 is the one auditors scrutinise: it demonstrates that the by default limb is a switch, not a wall. Precise geometry is reachable, but only through an explicit precise_geo_justified flag that a DPIA must back — and assertion 5 proves the by design floor still overrides even a justified request, so justification can loosen the default but never breach the absolute minimum.
Incident Response and Edge Cases
Article 25 controls fail in ways that pass ordinary tests, because a release with a plausible-looking cell size can still violate the limb that governs defaults. The scenarios below are the ones that surface in production.
- Purpose creep silently over-collects. A dataset ingested for
traffic_flow(500 m default) is later reused forfleet_routing(100 m floor) without re-running the DPIA. The finer purpose profile now admits releases the original consent never covered. Detection: key everyReleaseRequestto a purpose and reject any release whose purpose differs from the ingestion-time declaration. Remediation: treat a purpose change as a new processing operation — fresh DPIA, freshdpia_ref, re-derived parameters. - Default drift after a config edit. Someone lowers
min_cell_mto “improve utility,” quietly moving the default toward precise geo for every future release. Detection: pin eachPurposeProfileto the DPIA revision that authorised it and fail CI if a profile changes without a corresponding DPIA reference bump. Remediation: revert the profile and route the utility request through the justified-exception path instead, which is logged. - Budget cap satisfied per-release but breached cumulatively. A subject appears in many releases, each individually under
epsilon_cap, but their sum is not. This is the composition failure the Differential Privacy for Geospatial Data accountant exists to catch. Detection: theepsilonfield must carry the cumulative post-release total, sourced from the accountant, never the marginal spend. Remediation: on exhaustion, coarsen resolution to spend less per query or halt releases for that subject. - Retention honoured in the warehouse, not in the backups.
max_retention_daysis enforced on the primary store but stale copies survive in snapshots or a downstream lake. Detection: assert an erase job runs against every replica keyed by ingestion date. Remediation: propagate the retention window as record metadata so every store can self-expire, and log the erasure as accountability evidence.
Frequently Asked Questions
Does Article 25 require differential privacy specifically?
No — Article 25 is technology-neutral and names neither DP nor any mechanism. It requires that the effect (data minimisation, purpose limitation) be achieved by design and by default. DP is one defensible way to express the cumulative-ε limb because it gives a measurable, composable bound, but grid generalisation and truncation satisfy the resolution limb on their own. The point is that whatever mechanism you choose must resolve to enforceable parameters — a cell size, a budget, a retention window — not that it must be DP.
How is "by default" different from "by design" in code?
By design is a hard floor checked against every release regardless of intent: resolution can never go below the hard floor, ε never above the cap. By default is about which setting applies when nobody chooses one — the strictest option must be automatic. In the validator that difference is the precise_geo_justified flag defaulting to False: absent an explicit, documented justification, the coarsest cell serving the purpose is used, and only a deliberate exception unlocks finer geometry, and only down to the by-design floor.
What does the DPIA reference actually need to contain?
Enough to reproduce the parameter choices: the declared purpose, the chosen min_cell_m and hard_floor_m with the reasoning that ties them to the purpose, the cumulative epsilon cap and the accounting method behind it, the retention window, and the justification for any precise-geo exception. The validator only checks that a reference is present; the accountability limb is satisfied by making each release traceable to that record so an auditor can follow a published tile back to the decision that authorised its precision.
Can a data subject request finer data about themselves under Article 15?
A subject-access request returns the personal data you hold, but Article 25 governs what you process and release for the declared purpose, not what you disclose back to the individual. The two are separate lawful bases. The admission control here sits on the analytics-release path; a subject-access export runs through a different flow with its own authentication and logging, and it does not loosen the by-default resolution that analytical releases must honour.
Related
- Compliance Framework Mapping — the parent guide covering the sector-agnostic procedure this page applies to GDPR Article 25.
- Core Fundamentals & Architecture for Spatial Privacy — where this admission control sits in the end-to-end ingestion-to-audit pipeline.
- Privacy Budget Management — the accountant that supplies the cumulative ε the by-design cap is checked against.
- Spatial Sensitivity Scoring Models — how the per-purpose cell size and ε cap are calibrated to a measured risk tier.
- Differential Privacy for Geospatial Data — the mechanism most often used to make the cumulative-ε limb measurable and composable.
Up: Compliance Framework Mapping · Core Fundamentals & Architecture for Spatial Privacy