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

Relabelled versus reprojected, and what each leaves behind Two failure modes shown against their signatures. A relabel through set_crs leaves the coordinate values untouched while the declared code changes, so a code check passes and the geometry is in the wrong place. A stray reprojection leaves the declared code untouched while every coordinate moves, so a code check passes and the numbers are wrong. Only a combined check of code and geometric signature detects both. failure declared code coordinates caught by relabel · set_crs changed unchanged code check against the contract stray reprojection unchanged all moved geometric signature only datum substitution changed subtly shifted 1–2 m datum name comparison Three failures, three different detectors — which is why the check below records a signature rather than only reading a code.

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 signature

Key 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_crs when 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:4326 and EPSG:4269 are 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.
Signature checks at every boundary, not only at the door A four-stage pipeline with a signature assertion between each pair of stages. A drift introduced inside the transform stage is caught at the transform-to-validate boundary, which names the responsible stage. A single check at the end would report the same failure without indicating where it originated. ingest clean transform load drift attributed here a to_crs left in after a refactor One check at the sink would report the same failure and name the wrong stage, which is most of an investigation wasted.

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.

One stored signature answers less than thirty A plot of centroid offset over thirty runs. With only the previous value stored, the alert reports a single jump. With the history retained, the same data shows a steady creep beginning two weeks earlier, which points at a gradual source change rather than at a single bad run. centroid offset from baseline · metres alert at 5 m the creep starts here run 1 run 30 A single previous value would have reported only the last step, which reads as a one-off rather than a trend.

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”.