This guide is part of Validating Spatial Data Quality in Pipelines, within the broader Automated Vector & Raster Cleaning Workflows reference.
Detecting CRS Drift Between Pipeline Stages
A pipeline can lose track of its coordinate reference system in two ways, and neither raises an exception. Either the data was reprojected without the label changing, or the label changed without the data being reprojected.
Why CRS Drift Survives Every Other Check
- Both failures leave valid geometry. Every polygon is still well formed, so validity gates pass.
- Both leave plausible attributes. Nothing about the tabular data indicates that the coordinates moved.
- Joins fail silently. A layer in the wrong system intersects nothing, and an empty spatial join returns an empty frame rather than an error, as described in converting mixed EPSG codes to a unified CRS.
- Small shifts look like data updates. A datum substitution moves features by a metre or two, which is indistinguishable from a boundary revision unless something is measuring.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| pyproj | >=3.6 |
CRS.datum, CRS.equals with ignore_axis_order |
| GeoPandas | >=1.0 |
total_bounds, to_crs, estimate_utm_crs |
| Shapely | >=2.0 |
Vectorized area and centroid computation |
| PROJ | >=9.0 |
Grid-based datum transforms available when installed |
pip install "geopandas>=1.0" "pyproj>=3.6" "shapely>=2.0"The Two Failure Shapes
The spatial_signature Recipe
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
import geopandas as gpd
from pyproj import CRS
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SpatialSignature:
epsg: int | None
datum_name: str
is_projected: bool
bounds: tuple[float, float, float, float]
centroid: tuple[float, float]
median_area: float
feature_count: int
def spatial_signature(gdf: gpd.GeoDataFrame) -> SpatialSignature:
"""Summarise where a frame sits in space, in a form that is comparable across runs."""
if gdf.crs is None:
raise ValueError("frame carries no CRS — drift cannot be assessed")
crs = CRS.from_user_input(gdf.crs)
minx, miny, maxx, maxy = (round(v, 4) for v in gdf.total_bounds)
# Areas are only meaningful in a projected system; use an equal-area estimate otherwise.
measure = gdf if crs.is_projected else gdf.to_crs(gdf.estimate_utm_crs())
median_area = float(measure.geometry.area.median())
centre = measure.geometry.union_all().centroid
return SpatialSignature(
epsg=crs.to_epsg(),
datum_name=crs.datum.name if crs.datum else "unknown",
is_projected=crs.is_projected,
bounds=(minx, miny, maxx, maxy),
centroid=(round(centre.x, 3), round(centre.y, 3)),
median_area=round(median_area, 3),
feature_count=len(gdf),
)
def assert_no_drift(
gdf: gpd.GeoDataFrame,
stage: str,
expected_epsg: int,
baseline_path: Path,
centroid_tolerance_m: float = 5.0,
area_tolerance: float = 0.02,
) -> SpatialSignature:
"""Fail when the CRS, the datum or the geometric signature moved unexpectedly."""
signature = spatial_signature(gdf)
if signature.epsg != expected_epsg:
raise ValueError(f"{stage}: expected EPSG:{expected_epsg}, frame declares {signature.epsg}")
if baseline_path.exists():
previous = SpatialSignature(**json.loads(baseline_path.read_text()))
if previous.datum_name != signature.datum_name:
raise ValueError(
f"{stage}: datum changed from {previous.datum_name!r} to {signature.datum_name!r}"
)
dx = signature.centroid[0] - previous.centroid[0]
dy = signature.centroid[1] - previous.centroid[1]
shift = (dx * dx + dy * dy) ** 0.5
if signature.is_projected and shift > centroid_tolerance_m:
raise ValueError(f"{stage}: centroid moved {shift:.2f} m since the last run")
if previous.median_area:
drift = abs(signature.median_area - previous.median_area) / previous.median_area
if drift > area_tolerance:
raise ValueError(f"{stage}: median feature area moved {drift:.1%}")
baseline_path.write_text(json.dumps(asdict(signature)))
logger.info("%s: signature ok (epsg=%s, features=%d)", stage, signature.epsg,
signature.feature_count)
return signatureKey Implementation Notes
- The centroid is computed in a projected system. A centroid shift measured in degrees means different distances at different latitudes, so the comparison uses metres via
estimate_utm_crswhen the frame is geographic. - Median area is preferred over total area. Total area moves whenever features are added or removed; the median of the per-feature areas is stable under growth and highly sensitive to a reprojection.
- The datum name is compared, not only the EPSG code.
EPSG:4326andEPSG:4269are both degrees and both look correct; their datums differ, and so do the coordinates by up to a couple of metres. - The baseline is written after the checks pass. Writing it first would record the drifted signature as the new normal, which silently accepts the very failure the check exists to catch.
- Bounds are rounded before storage. Floating-point noise in the fourth decimal place otherwise produces a diff on every run and trains readers to ignore the field.
- A missing baseline is not a failure. The first run establishes the reference; only subsequent runs can drift.
Troubleshooting Drift Alerts
| Symptom | Likely cause | Fix |
|---|---|---|
| Centroid alert on a growing dataset | New features at the edge of the extent | Compare median area instead of centroid for growing layers |
| Datum alert after a container rebuild | PROJ grid files missing, transform fell back | Pin the PROJ data in the image and assert at startup |
| Signature check raises on the first run | No baseline yet | Treat a missing baseline as a write, not a failure |
| Bounds diff on every run | Rounding not applied before comparison | Round to a fixed precision before storing |
| Alert fires after a legitimate reprojection | Baseline not reset when the contract changed | Delete the baseline as part of the deliberate change |
Where the Baseline Should Live
A baseline stored on a worker’s local disk is a baseline that disappears at the next deployment, which converts every drift check into a no-op that reports success. Put it in the same durable store as the data — an object under the pipeline’s state prefix, or a small table alongside the run metrics — keyed by dataset and stage.
Two properties matter. It must be readable before the run and writable after it, which rules out anything that requires a deployment to change. And it must be versioned or at least timestamped, because the question that follows a drift alert is invariably “when did this last look normal?”, and a single overwritten value cannot answer it.
Keeping the last thirty signatures rather than only the newest costs a few kilobytes and turns the alert into a series. A centroid that moved once is an incident; a centroid that has been creeping for two weeks is a different problem with a different cause, and only the history distinguishes them.
Integration Note
The assertion belongs at each stage boundary in the pipeline, alongside the schema validation from writing geometry validity assertions with pandera. Give it blocking severity: unlike a per-row geometry failure, CRS drift invalidates every row, so quarantine is not a meaningful response. Where a pipeline deliberately changes reference system between stages, encode the expected code per stage rather than asserting one value everywhere — the contract is “this stage emits EPSG:27700”, not “everything is EPSG:4326”.