Detecting CRS Drift in CI Pipelines

A coordinate reference system change that reaches production is expensive to unwind, but the same change is trivial to catch at the pull request that introduces it. When a transform step is edited, a source connector is repointed, or a vendor reissues a feed in a new projection, the SRID of the data a build produces can silently diverge from the value every downstream join assumes — and continuous integration is the cheapest place on earth to fail that build. This guide wires a deterministic CRS gate into a CI pipeline so a projection change fails the pull request instead of the field team’s map. It sits under Coordinate Reference System Validation and within the wider Spatial Data Freshness & Quality Metrics program, and it is written for the data engineers and SREs who own the deployment pipeline.

CI gate asserting a data source SRID against a pinned baseline on every pull request A pull request triggers a CI job that loads a data-source fixture, extracts its SRID with ST_SRID and pyproj, and compares it against a pinned baseline SRID stored in the repository. A match passes the check and allows merge. A mismatch increments the crs_drift counter, fails the job, and blocks the merge until the baseline is deliberately updated. Pull request — SRID asserted against a pinned baseline Pull request transform edit Extract SRID ST_SRID · pyproj from fixture Pinned baseline expected_srid.yml SRID == baseline? PASS · merge datum contract held FAIL · block crs_drift_total++ yes no

What CI can catch that runtime cannot

Runtime CRS validation, covered in the parent Coordinate Reference System Validation guide, watches the data flowing through a live pipeline. A CI gate watches something different and complementary: the code and configuration that will produce that data. It fails the change before it ever runs against real data, so the projection regression never consumes an ingestion window, never mislocates a published feature, and never needs a quarantine sweep. The two are layers of the same defense — CI asserts intent, runtime asserts reality.

Three change patterns are worth gating in CI specifically, because each is introduced by an edit a reviewer can miss:

  • A repointed source. A connector’s URL or table reference is changed to a feed that happens to publish in a different projection. Row counts and geometry validity look fine; only the SRID moved.
  • An edited transform. A ST_Transform target is changed, an always_xy flag flipped, or a reprojection removed entirely, so the build’s output CRS silently changes.
  • A dependency bump. A pyproj, GDAL, or PROJ upgrade shifts a datum-transformation pipeline or a grid-shift file, changing the numeric result of a reprojection even though the SRID label is identical.

The CI gate asserts against a pinned baseline — an expected_srid committed to the repository — so that changing the projection a build produces requires deliberately editing that baseline in the same pull request, where a reviewer sees it. This is the projection-side analogue of the longitudinal tracking in Validating Coordinate Reference System Drift Over Time: that guide watches drift across production runs, this one blocks it at the commit.

The SRID assertion in PostGIS

The core check is a single deterministic assertion: the SRID the pipeline emits for a given source equals the pinned expected value. When the build materializes a fixture into PostGIS, ST_SRID reads the authoritative value straight from the geometry, and a mismatch is a fail — not a warning, because a single wrong SRID is a correctness failure, not a quality dip.

-- CI assertion: every geometry in the build output carries the expected SRID
SELECT
  ST_SRID(geom)      AS observed_srid,
  32633              AS expected_srid,   -- WGS 84 / UTM 33N, pinned in repo
  count(*)           AS n
FROM ci_build.parcels_output
GROUP BY ST_SRID(geom)
HAVING ST_SRID(geom) IS DISTINCT FROM 32633;
-- Any returned row is a drift: the job must exit non-zero.

For a source that arrives already projected, also assert that the transform actually happened rather than the SRID metadata merely being relabelled — a common silent bug where ST_SetSRID is used where ST_Transform was meant. Compare a known control coordinate’s projected position against its expected value within a tight tolerance so a metadata-only relabel is caught even when ST_SRID looks correct.

Wiring the gate with pytest

pyproj gives the same assertion for file-based fixtures that never touch a database, and pytest turns each source contract into a named, individually-reporting test. The parametrized form keeps one row per monitored source, so a CI failure names exactly which feed drifted.

# test_crs_contract.py
import pytest
import geopandas as gpd
from pyproj import CRS

# Pinned baseline — editing this is a reviewed, deliberate act
EXPECTED_SRID = {
    "parcels":   32633,   # WGS 84 / UTM 33N
    "basemap":   3857,    # Web Mercator
    "sensors":   4326,    # WGS 84 geographic
}

@pytest.mark.parametrize("source,expected", EXPECTED_SRID.items())
def test_source_srid_matches_baseline(source, expected):
    gdf = gpd.read_file(f"fixtures/{source}.gpkg")
    assert gdf.crs is not None, f"{source}: no CRS metadata — refusing to guess"
    observed = gdf.crs.to_epsg()
    assert observed == expected, (
        f"CRS drift in '{source}': expected EPSG:{expected}, got EPSG:{observed}. "
        f"If intentional, update EXPECTED_SRID in the same PR."
    )

def test_transform_is_datum_correct():
    # Guard against a pyproj/PROJ upgrade silently changing a datum shift:
    # a control point's reprojected position must stay within 1 mm.
    tf = CRS.from_epsg(4326)
    to = CRS.from_epsg(32633)
    from pyproj import Transformer
    x, y = Transformer.from_crs(tf, to, always_xy=True).transform(15.0, 60.0)
    assert abs(x - 500000.0) < 0.001 and abs(y - 6651411.0) < 1.0

The always_xy=True contract is load-bearing here for the same reason it matters at runtime: it prevents the easting/northing transposition that a latitude-first CRS invites. That failure has its own subtlety — a swapped-axis dataset can pass an SRID check cleanly — and detecting it is covered in Catching Axis-Order Swaps in CRS Validation.

The GitHub Actions job and the drift metric

The workflow runs the assertion on every pull request and, on failure, both blocks the merge and records the event so recurring drift is visible as a trend rather than a one-off red check. Emitting gis.spatial.crs_drift_total from CI puts pipeline-change drift in the same series as runtime drift, so a Grafana panel shows both the build that introduced a projection change and the production run that first served it.

name: crs-contract
on: [pull_request]

jobs:
  crs-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install geo stack
        run: pip install "pyproj>=3.6" geopandas pytest
      - name: Assert SRID contract
        run: pytest test_crs_contract.py -v --junitxml=crs.xml
      - name: Record drift metric on failure
        if: failure()
        run: |
          cat <<'EOF' | curl --data-binary @- \
            http://pushgateway.internal:9091/metrics/job/ci_crs_gate/pr/${{ github.event.number }}
          # TYPE gis_spatial_crs_drift_total counter
          gis_spatial_crs_drift_total{stage="ci",repo="${{ github.repository }}"} 1
          EOF

Because CI drift is an event, not a rate, the alert keys on any increase over a short window, and a repeated CI failure on the same source is the signal that a vendor genuinely changed a projection and the baseline — not the code — needs a reviewed update:

groups:
  - name: spatial-crs-ci
    rules:
      - alert: CrsDriftBlockedInCI
        expr: increase(gis_spatial_crs_drift_total{stage="ci"}[1h]) > 0
        labels: { severity: warning }
        annotations:
          summary: "CRS contract failed in CI for {{ $labels.repo }} — merge blocked"

Keep the fixtures small and version-controlled so the gate runs in seconds and stays deterministic; a drifting SRID should be the only reason this job ever goes red. When it does, treat the failure the way the runtime pipeline treats a quarantined partition — investigate the source before touching the baseline — and reconcile against Tracking Spatial Data Freshness SLAs if the same change also stalled a reprojection and inflated ingestion lag.

FAQ

Why gate SRID in CI when the runtime pipeline already validates it?

Cost and timing. Runtime validation catches a projection change after it has consumed an ingestion window and possibly mislocated published features; CI catches the code or config change that causes it before it runs at all. A failed pull request costs a reviewer a minute; a projection regression in production costs a quarantine sweep, a backfill, and a corrected map. Run both — they cover different moments.

What exactly should the pinned baseline contain?

The expected EPSG code per data source, committed to the repository as data (a YAML or Python dict), not embedded in a transform. Keeping it as a reviewed artifact means changing the projection a build produces requires editing the baseline in the same pull request, where a reviewer sees the intent. It doubles as documentation of every source’s contractual datum.

How do I catch a pyproj or PROJ upgrade that changes results without changing the SRID?

Assert a control point. Reproject a fixed coordinate whose expected output you know and check it stays within a tight tolerance — millimetres for a projected target. A datum-transformation or grid-shift change from a dependency bump moves that point even though the SRID label is identical, so the control-point test catches what an SRID equality check cannot.

Should a CRS mismatch fail the build or just warn?

Fail it. A single geometry with the wrong SRID is not a small quality dip that averages out — it is a correctness failure that mislocates every feature and silently breaks every downstream join. Reserve warnings for sub-tolerance transform noise; treat any SRID inequality against the baseline as a hard, merge-blocking failure.

How does this relate to catching axis-order swaps?

They are complementary CI checks. The SRID assertion confirms the projection identity is right; an axis-order check confirms the coordinate ordering within that projection is right. A dataset can pass the SRID gate while its coordinates are transposed, so pair this gate with the bounding-box and axis assertions in Catching Axis-Order Swaps in CRS Validation.