The Python Spatial Privacy Toolkit
The Python spatial privacy toolkit is the small, stable set of libraries an engineer composes to move location data from raw coordinates to a defensibly anonymized release: geometry and projection, spatial indexing, noise and masking mechanisms, mobility handling, and statistical validation.
Almost every technique on this site — from applying Laplace and Gaussian noise to coordinate data to k-anonymity grouping for location traces — rests on the same handful of packages. This page is the canonical, version-pinned reference those guides link to from their prerequisites. It documents what each library is for, its privacy-relevant APIs and gotchas, the coordinate reference system (CRS) conventions that matter, and how the libraries fit together in one pipeline.
The Toolkit by Pipeline Stage permalink
The libraries are best understood by the stage of the pipeline they serve rather than alphabetically. Geometry and projection tools ingest and normalise the data; an indexing layer discretises space for grouping; masking and differential-privacy mechanisms inject the actual privacy protection; mobility tools handle trajectories; and statistical tools validate that the release is both safe and useful.
Library Reference permalink
Each subsection below gives the library’s role, a version-pinned install command, its privacy-relevant APIs, and one gotcha that has burned real pipelines. The versions form a mutually compatible set; the consolidated requirements block follows.
geopandas permalink
Role. geopandas is the top-level container for the whole pipeline. It extends pandas with a geometry column and a CRS attribute, so every point, buffer and cell travels with its spatial reference attached. Loading, reprojecting, spatial-joining and writing GeoJSON or GeoPackage all happen through the GeoDataFrame.
pip install geopandas==1.0.1
Privacy-relevant APIs. GeoDataFrame.to_crs() performs the reprojection that every masking operation depends on — you mask in metres, not degrees. gpd.sjoin(points, cells, predicate="within") is the containment join behind grid aggregation and spatial binning and behind spatial k-anonymity grouping. dissolve(by=..., aggfunc="count") collapses records into groups and is how you compute per-cell counts before applying a suppression floor.
CRS convention. A GeoDataFrame read from WGS84 GeoJSON arrives as EPSG:4326 (degrees). Reproject to a projected metric CRS — a UTM zone for a single region, or EPSG:3857 for global web tiles — before any distance, buffer or noise step, then reproject the release back to EPSG:4326.
Gotcha. geopandas will happily let you buffer(100) a layer that is still in EPSG:4326, silently treating the radius as 100 degrees (roughly 11,000 km). It does not warn. Always assert gdf.crs.is_projected before a metric operation.
shapely permalink
Role. shapely is the geometry engine underneath geopandas — points, lines, polygons and the predicates and operations on them. When you need per-geometry control (a custom donut buffer, a validity repair, a nearest-neighbour test) you reach past geopandas into shapely directly.
pip install shapely==2.0.6
Privacy-relevant APIs. shapely.geometry.Point(x, y) reconstructs a masked coordinate after you perturb the raw values with numpy. Point.buffer(radius) builds the circular zones behind spatial fuzzing and buffer-zone implementation. shapely.validation.make_valid() repairs self-intersecting or null geometries that would otherwise crash a spatial join mid-pipeline and skew counts.
CRS convention. shapely is CRS-agnostic — it treats coordinates as bare numbers. That is exactly why you must guarantee the numbers are metres before you buffer or measure distance; shapely will not stop you from buffering degrees.
Gotcha. shapely 2.0 was a breaking rewrite (vectorised, no more .ctypes, cascaded_union removed in favour of unary_union). Code and tutorials written for 1.8 will fail on 2.0. Pin to a single 2.x release and do not mix with libraries that still require shapely<2.
pyproj permalink
Role. pyproj is the projection and datum-transformation library (a binding to PROJ). geopandas delegates all CRS work to it. You use pyproj directly for high-throughput coordinate transforms and to reason precisely about which datum shift is applied.
pip install pyproj==3.6.1
Privacy-relevant APIs. pyproj.Transformer.from_crs("EPSG:4326", "EPSG:32631", always_xy=True) builds a reusable, vectorised transformer for millions of points — far faster than per-row to_crs. Geod.inv() returns true geodesic distances on the ellipsoid, which you need to validate that a masking displacement actually achieved its intended radius on the ground.
CRS convention. pyproj is where WGS84-versus-projected becomes explicit. Choosing the correct UTM zone (EPSG:326xx north, 327xx south) matters: transforming through the wrong zone introduces metre-scale distortion that can leak or exaggerate the true location relative to the intended privacy radius.
Gotcha. Axis order. always_xy=True forces longitude-latitude (x, y) ordering; omit it and pyproj follows the CRS authority definition, which for EPSG:4326 is latitude-longitude. A flipped axis silently sends every point to the wrong hemisphere. Always pass always_xy=True unless you have a specific reason not to.
h3 permalink
Role. h3 is Uber’s hierarchical hexagonal grid index. It maps every coordinate to a fixed cell identifier at a chosen resolution, giving you a discrete, equal-ish-area binning key that is ideal for grouping, aggregation and k-anonymity without generating an explicit grid geometry.
pip install h3==4.1.0
Privacy-relevant APIs. h3.latlng_to_cell(lat, lng, resolution) returns the cell that a point falls in — the grouping key for aggregation and for enforcing a minimum group size. h3.cell_to_latlng(cell) returns the cell centre, the coordinate you publish in place of the raw point. h3.grid_disk(cell, k) enumerates the k-ring neighbourhood, useful when auditing whether a released cell is isolated.
CRS convention. h3 consumes and returns WGS84 latitude/longitude in degrees — it is one of the few stages that legitimately works in EPSG:4326. Feed it geographic coordinates directly; do not project first.
Gotcha. The 3.x-to-4.x API rename is the classic trap: geo_to_h3 became latlng_to_cell, h3_to_geo became cell_to_latlng, and k_ring became grid_disk. Mixing a 4.x pin with 3.x example code fails immediately, and — more subtly — cell resolution choice is a privacy parameter: resolution 8 (~0.7 km² hexes) versus resolution 9 (~0.1 km²) changes the effective anonymity set dramatically.
numpy permalink
Role. numpy is the numerical substrate. Every noise sample, coordinate array and distance matrix is a numpy array, and it is the fastest path to the random perturbations behind masking.
pip install numpy==1.26.4
Privacy-relevant APIs. np.random.default_rng(seed) returns a modern Generator; its .laplace(loc, scale, size) and .normal(loc, scale, size) draw the perturbations used in coordinate jittering and noise injection. Vectorised array arithmetic applies a metre-scaled offset to thousands of projected coordinates at once.
CRS convention. numpy sees only numbers, so the scale you pass to .laplace or .normal is in whatever unit the array holds. Feed it projected metres; feeding degrees makes the noise magnitude latitude-dependent and the privacy radius meaningless.
Gotcha. Seeding for reproducibility is a double-edged sword. A fixed seed makes an audit reproducible, but if the same seed and mechanism are reused across releases an attacker who learns one offset can subtract it everywhere. Seed for testing; use fresh entropy (or a securely managed key) in production. Also note the compatibility cliff: many geospatial and DP wheels still expect the numpy 1.x ABI, so numpy 2.x can break opendp or older geopandas builds — hence the 1.26.x pin.
scipy permalink
Role. scipy supplies the statistical distributions, spatial data structures and hypothesis tests that sit on top of numpy. In a privacy pipeline it does double duty: parameterising noise mechanisms and validating the result.
pip install scipy==1.13.1
Privacy-relevant APIs. scipy.stats.laplace and scipy.stats.norm give you the CDF/PPF needed to reason about the tail probability of a masking displacement — how likely the true point lands outside the intended radius. scipy.spatial.cKDTree powers fast nearest-neighbour queries for the auxiliary-join re-identification tests described in re-identification risk assessment. scipy.stats.entropy quantifies the anonymity set diversity within a group.
CRS convention. cKDTree computes Euclidean distance, which is only meaningful in a projected metric CRS. Build the tree from projected coordinates; querying a tree built from lat/lon returns distances in degrees that mean nothing for a privacy threshold.
Gotcha. cKDTree.query returns Euclidean distance even when your data is geographic — it does not know the earth is round. Over large extents this understates true ground distance. Project first, or use pyproj.Geod for exact geodesic checks on the final release.
opendp permalink
Role. opendp is the formally-verified differential privacy library from the OpenDP project. Unlike hand-rolled numpy noise, it tracks the sensitivity of a query, converts it to a rigorous epsilon, and composes multiple releases under a total budget — the machinery a defensible differential privacy claim requires.
pip install opendp==0.11.1
Privacy-relevant APIs. After import opendp.prelude as dp; dp.enable_features("contrib"), you build measurements such as dp.m.make_laplace(input_domain, input_metric, scale) and combine them with a known sensitivity via the map to obtain a certified epsilon. Composition combinators account for the budget across the multiple spatial queries described in privacy budget allocation for spatial queries, and the framework underpins even local differential privacy for mobile clients.
CRS convention. opendp operates on numbers, so bound the query sensitivity in metres in a projected CRS. The sensitivity of a coordinate mean, for instance, depends on the metric clamp bounds you set — express those bounds in the same projected units as the data.
Gotcha. enable_features("contrib") is mandatory to access most spatial-relevant mechanisms, and the API surface still moves between minor releases (the dp.m. / dp.t. namespaces and constructor names have changed across 0.9–0.11). Pin one version and re-verify the epsilon after any upgrade — a silent change to sensitivity accounting changes your realised guarantee.
scikit-mobility (skmob) permalink
Role. scikit-mobility, imported as skmob, is the dedicated library for human mobility data. It provides a TrajDataFrame type and built-in privacy operations for GPS traces, so you do not reimplement trajectory anonymization from scratch.
pip install scikit-mobility==1.3.1
Privacy-relevant APIs. skmob.TrajDataFrame wraps user-tagged, timestamped points. The skmob.privacy.attacks module models re-identification adversaries directly, and skmob.preprocessing offers stop detection and compression that feed trajectory anonymization techniques and the worked example on implementing trajectory k-anonymity with scikit-mobility. Its generative models support synthetic mobility data generation.
CRS convention. skmob assumes WGS84 latitude/longitude and computes distances with a haversine metric internally, so pass geographic coordinates. When you need projected-CRS operations (buffering, metric noise) round-trip through geopandas rather than fighting skmob’s geographic assumptions.
Gotcha. skmob has a heavy dependency footprint (it pulls geopandas, scikit-learn and folium) and lags the latest numpy/pandas releases, so it is the package most likely to dictate the versions of everything else. Resolve the environment around skmob first, then pin the rest to match.
pandas permalink
Role. pandas is the tabular foundation geopandas extends. Every non-spatial attribute — timestamps, user identifiers, quasi-identifiers — lives in a pandas column, and the group-count logic that enforces k-anonimity is pandas groupby.
pip install pandas==2.2.2
Privacy-relevant APIs. groupby(cell_id).size() produces the per-group counts you compare against a k floor; groupby().filter(lambda g: len(g) >= k) implements the suppression directly. pd.cut and pd.qcut generalise continuous quasi-identifiers (age, speed, dwell time) into bins, a prerequisite for meaningful group anonymity. Careful merge control matters because a careless join is itself an auxiliary-data linkage.
CRS convention. pandas has no CRS concept. The risk is that once you drop out of geopandas into a plain DataFrame (for speed) you can lose track of whether coordinate columns are degrees or metres. Name columns explicitly (lon_deg, x_m) so the unit is never ambiguous.
Gotcha. pandas will silently re-order or drop rows on a merge with a non-unique key, corrupting the mapping between a user and their cell. It also upcasts integer identifier columns to float when NaNs appear, mangling IDs. Validate join cardinality (validate="one_to_one") on any merge that touches an identifier.
Consolidated Requirements permalink
Pin every version together. A single requirements.txt is the artefact your data protection impact assessment references as the exact environment in which a stated epsilon or k was achieved.
# spatial privacy toolkit — mutually compatible, pinned set
geopandas==1.0.1
shapely==2.0.6
pyproj==3.6.1
h3==4.1.0
numpy==1.26.4
scipy==1.13.1
opendp==0.11.1
scikit-mobility==1.3.1
pandas==2.2.2
esda==2.5.1 # Moran's I and spatial autocorrelation for utility checks
Install into a fresh virtual environment and capture the fully-resolved lock (pip freeze > constraints.txt) so transitive dependencies — PROJ data, GEOS, the numpy ABI — are frozen too, not just the top-level packages.
Resolve the environment around scikit-mobility first: it has the tightest upper bounds on numpy and pandas, and letting it pick those versions avoids the most common dependency conflict. The esda entry brings the Moran’s I implementation from the PySAL family; it is separated from the core set because a pipeline that never validates spatial autocorrelation can omit it. Everything else in the list is load-bearing for at least one masking or differential-privacy technique on the site. When a package must be upgraded — a security patch in pyproj’s PROJ, say — bump one line, rebuild the lock, and re-run the validation suite before the new environment is allowed to produce a release, because an unverified upgrade silently invalidates the previously stated guarantee.
Composing Them: A Minimal End-to-End Pipeline permalink
The libraries compose in the pipeline order of the diagram: project with pyproj/geopandas, index with h3, mask with numpy (or certify with opendp), then validate with scipy and esda. The hand-off points are where bugs and privacy leaks hide: a coordinate that is still in degrees when it reaches the noise step, a CRS attribute dropped when data falls out of geopandas into a bare pandas frame, a cell identifier computed at the wrong h3 resolution. The function below is a minimal, runnable-looking skeleton that makes each hand-off explicit and keeps the unit of every coordinate unambiguous.
"""Minimal end-to-end spatial privacy pipeline: project -> index -> mask -> validate."""
from __future__ import annotations
import geopandas as gpd
import numpy as np
import h3
from scipy.stats import entropy
def anonymize_points(
points_wgs84: gpd.GeoDataFrame,
utm_epsg: int = 32631, # projected metric CRS for this region (UTM 31N)
h3_resolution: int = 8, # ~0.7 km2 hexes -> the anonymity-set granularity
k_floor: int = 5, # minimum records per cell before release
noise_scale_m: float = 50.0, # Laplace scale in METRES (projected units)
seed: int | None = None, # None = fresh entropy in production; fix only for tests
) -> gpd.GeoDataFrame:
"""Project, h3-index, group under a k floor, jitter cell centres, return safe points.
The privacy protection comes from three composed controls: aggregation to h3
cells, k-anonymity suppression of small cells, and metric-CRS Laplace noise on
the released centres so cell centres are not exact-linkable to a public grid.
"""
rng = np.random.default_rng(seed)
# 1. INDEX: h3 works in WGS84 degrees -> assign each point its cell key.
df = points_wgs84.copy()
df["cell"] = [
h3.latlng_to_cell(pt.y, pt.x, h3_resolution) # (lat, lng, res)
for pt in df.geometry
]
# 2. K-ANONYMITY: drop any cell that does not meet the k floor (no existence leak).
counts = df.groupby("cell").size()
safe_cells = counts[counts >= k_floor].index
df = df[df["cell"].isin(safe_cells)]
# 3. AGGREGATE: publish one record per surviving cell at its centre.
centres = {c: h3.cell_to_latlng(c) for c in df["cell"].unique()} # (lat, lng)
out = gpd.GeoDataFrame(
{"cell": list(centres)},
geometry=gpd.points_from_xy(
[lng for _, lng in centres.values()],
[lat for lat, _ in centres.values()],
),
crs="EPSG:4326",
)
# 4. MASK in a PROJECTED CRS so the noise scale is true metres, not degrees.
proj = out.to_crs(epsg=utm_epsg)
proj["geometry"] = gpd.points_from_xy(
proj.geometry.x + rng.laplace(0.0, noise_scale_m, len(proj)),
proj.geometry.y + rng.laplace(0.0, noise_scale_m, len(proj)),
crs=proj.crs,
)
# 5. VALIDATE: anonymity-set diversity should not collapse (entropy > 0).
set_sizes = counts[counts >= k_floor].to_numpy(dtype=float)
assert entropy(set_sizes) > 0.0, "released cells collapsed to a single group"
# Reproject to WGS84 only at the very end, for publication.
return proj.to_crs(epsg=4326)
For a formal guarantee, swap step 4 for an opendp measurement whose scale is derived from a bounded sensitivity, and track the spend against a privacy budget. For the validation stage, esda’s Moran implements the utility-preservation metric that confirms the masked surface still carries the raw data’s clustering structure, as detailed in computing Moran’s I on masked spatial data.
Compliance & Reproducibility permalink
A pinned, locked environment is not housekeeping — it is an audit requirement. Under GDPR Article 35, a data protection impact assessment must document the technical measures applied to reduce risk; “we added Laplace noise” is unfalsifiable without the exact library versions, CRS transformation chain and parameters that produced it. The same discipline supports the CCPA and HIPAA de-identification records and NIST SP 800-188’s requirement that de-identification parameters be recorded.
Because a minor version bump in pyproj can change a datum transformation, in h3 can change cell identifiers, and in opendp can change sensitivity accounting, the realised privacy guarantee is a property of the whole pinned stack, not of the algorithm in the abstract. Commit requirements.txt, the resolved constraints.txt lock, and the parameter manifest together, and reference them from your spatial privacy audit report so an assessor can rebuild the exact environment and reproduce the stated k or epsilon. Which mechanism to reach for in that stack is a separate decision — see masking versus differential privacy technique selection and the broader GDPR/CCPA compliance mapping.
FAQ permalink
Which library actually provides the privacy guarantee? None of them provides it by itself. geopandas, shapely and pyproj move and reproject geometry; h3 and pandas group it; numpy, scipy and opendp inject the mechanism. The guarantee is a property of how you compose them plus the parameters (k, epsilon, radius) you choose — which is why the version pin and the parameter manifest, not any single package, are what an audit examines.
Can I run the whole toolkit in latitude/longitude to avoid reprojection? No. h3, skmob and the group-by steps are fine in WGS84, but every metric operation — buffering, Laplace/Gaussian noise, KD-tree distance — must run in a projected CRS or the privacy radius varies with latitude by a factor of . Reproject to a UTM zone or EPSG:3857 for those steps and back to EPSG:4326 only for publication.
Why pin numpy to 1.26 rather than a 2.x release? Several geospatial and differential-privacy wheels are still built against the numpy 1.x ABI, so numpy 2.x can break opendp or older geopandas/skmob builds at import time. Pinning numpy to 1.26.x keeps the whole set installable and is the safest choice until every downstream package publishes 2.x-compatible wheels.
Do I need both scipy and opendp? They serve different roles. scipy parameterises and validates — distributions, KD-trees, entropy, hypothesis tests. opendp certifies — it turns a bounded-sensitivity query into a rigorous epsilon and composes budgets. Prototype and validate with scipy; release under a stated epsilon with opendp.
Related permalink
- Applying Laplace & Gaussian Noise to Coordinate Data — the mechanism numpy, scipy and opendp implement
- Coordinate Jittering & Noise Injection Methods — the masking stage in projected metres
- k-Anonymity Grouping for Location Traces — the group-and-suppress pattern built on pandas and h3
- Trajectory Anonymization Techniques — where scikit-mobility takes over
- Masking vs. Differential Privacy Technique Selection — choosing which part of the toolkit to reach for