Automating CRS Validation in CI Pipelines

Coordinate Reference System (CRS) mismatches remain one of the most insidious failure modes in spatial data engineering. Unlike syntactic schema violations, CRS drift often passes initial ingestion, only to surface downstream as silent geometric distortion, failed spatial joins, or corrupted tile caches. This page shows how to build a deterministic CRS gate with pyproj, GDAL, and GitHub Actions so that no dataset reaches staging with the wrong authority code or axis order. It is a concrete implementation of the broader scoping rules for map data validation — the parent guide that defines which contracts each layer must satisfy before it enters a pipeline.

Root-Cause Framing: Why CRS Validation Fails in CI

When CRS validation breaks in continuous integration, the failure rarely stems from a single malformed file. It emerges from a convergence of library version drift, lazy evaluation semantics, and ambiguous authority definitions.

  1. PROJ 6+ axis-order reversal. Modern pyproj and GDAL default to axis_order=always_xy for geographic CRSs, whereas legacy pipelines assumed lat,lon. CI runners with updated dependencies will reject datasets that previously passed, or — worse — silently invert coordinates during transformation. The PROJ documentation details this behavioral shift and mandates explicit axis-order declarations.
  2. WKT vs. PROJ-string drift. Many ingestion tools serialize CRS as PROJ4 strings, which lack axis metadata and authority codes. When downstream consumers parse these strings, pyproj cannot guarantee equivalence to the original EPSG definition, causing assertion mismatches during spatial joins.
  3. Lazy GDAL evaluation. Libraries like rasterio and geopandas defer CRS parsing until data is explicitly accessed. In CI, validation scripts that only inspect file headers may pass while actual coordinate transformations fail during spatial operations.
  4. Deprecated EPSG codes. The EPSG Geodetic Parameter Registry periodically retires or redefines codes (for example EPSG:3857 vs. ESRI:102100). Pipelines that hardcode expected codes without an equivalence policy fail on perfectly valid legacy datasets.

Grounding these failure vectors in the wider discipline of geospatial QA fundamentals and architecture matters because a CRS check is only useful when it is treated as a first-class contract rather than an afterthought.

Where CRS Checks Sit: Pyramid Layer and Assertion Type

CRS validation must align with established testing methodology. Within the GIS test pyramid, CRS checks belong at the base: fast, deterministic, executed on every commit, and never deferred to heavy integration or end-to-end rendering tests.

When designing the logic, map each check to the right category in spatial assertion types. CRS validation is a structural assertion (metadata correctness) rather than a geometric assertion (topology or coordinate bounds). Structural assertions fail fast, consume minimal memory, and integrate cleanly into pre-commit hooks. Decoupling CRS validation from heavy spatial operations gives sub-second feedback without sacrificing accuracy.

Parameter Reference

The validator below is driven by a small, version-controlled config. The table fixes the contract each key enforces and the library that owns it.

Config key Type Library / API Purpose Default
expected_crs str | int | CRS pyproj.CRS.from_user_input Canonical authority code the layer must match — (required)
axis_order str pyproj axis-info direction Enforce always_xy vs. authority ordering always_xy
vector_exts set[str] geopandas.read_file Extensions routed to the vector path .geojson .shp .gpkg .parquet
raster_exts set[str] rasterio.open Extensions routed to the raster path .tif .tiff .nc .vrt
roundtrip_tol_m float pyproj.Transformer Max residual for a forward+inverse round trip 1e-3
allow_equivalent bool CRS.equals(..., ignore_axis_order) Accept ESRI:102100EPSG:3857 True

CRS identity itself is boolean, but where a layer is reprojected, a round-trip check guards against lossy transformation pipelines. Transform a representative coordinate forward into the target CRS and back, then require the planar residual to stay within roundtrip_tol_m:

ε=pT1 ⁣(T(p))2τroundtrip \varepsilon = \bigl\lVert \mathbf{p} - T^{-1}\!\left(T(\mathbf{p})\right) \bigr\rVert_2 \le \tau_{\text{roundtrip}}

where p\mathbf{p} is the source coordinate, TT the forward transformation, and τroundtrip\tau_{\text{roundtrip}} the tolerance in target-CRS linear units (metres for projected, degrees for geographic — never mix the two in one threshold).

Step-by-Step Implementation

Step 1 — Build the validation engine (pyproj 3.6+, geopandas 0.14+, rasterio 1.3+)

The module routes each dataset by extension, then enforces a single CRS-equality gate shared by the vector and raster paths. Authority codes are normalized to pyproj.CRS so vector and raster definitions are compared on equal footing.

# crs_validator.py
import logging
from pathlib import Path
from typing import Union

import geopandas as gpd
import rasterio
from pyproj.crs import CRS
from pyproj.exceptions import CRSError

logger = logging.getLogger(__name__)


class CRSValidationError(Exception):
    """Raised when a dataset fails CRS validation against expected parameters."""


class CRSValidator:
    """Deterministic CRS validator for CI/CD pipelines."""

    def __init__(
        self,
        expected_crs: Union[str, int, CRS],
        axis_order: str = "always_xy",
    ):
        self.expected_crs = CRS.from_user_input(expected_crs)
        self.axis_order = axis_order

    def _resolve_axis_order(self, crs: CRS) -> str:
        """Determine if a geographic CRS uses XY or YX axis ordering."""
        try:
            return crs.axis_info[0].direction if crs.is_geographic else "xy"
        except IndexError:
            return "unknown"

    def validate_vector(self, path: Path) -> bool:
        """Validate CRS for vector datasets (GeoJSON, Shapefile, GeoPackage, ...)."""
        # rows=0 reads schema only — no geometry data loaded into memory
        gdf = gpd.read_file(path, rows=0)
        actual_crs = gdf.crs
        if actual_crs is None:
            raise CRSValidationError(f"No CRS defined in {path}")

        if not actual_crs.equals(self.expected_crs):
            raise CRSValidationError(
                f"CRS mismatch in {path}: expected {self.expected_crs.to_epsg()}, "
                f"found {actual_crs.to_epsg()}"
            )

        axis = self._resolve_axis_order(actual_crs)
        if axis != self.axis_order and actual_crs.is_geographic:
            logger.warning(
                "Axis order mismatch in %s: expected %s, found %s",
                path, self.axis_order, axis,
            )
        return True

    def validate_raster(self, path: Path) -> bool:
        """Validate CRS for raster datasets (GeoTIFF, NetCDF, ...)."""
        with rasterio.open(path) as src:
            if not src.crs:
                raise CRSValidationError(f"No CRS defined in raster {path}")

            # rasterio returns its own CRS type; normalize to pyproj for comparison
            actual_crs = CRS.from_wkt(src.crs.to_wkt())
            if not actual_crs.equals(self.expected_crs):
                raise CRSValidationError(
                    f"Raster CRS mismatch in {path}: expected {self.expected_crs.to_epsg()}, "
                    f"found {actual_crs.to_epsg()}"
                )
        return True

    def run(self, dataset_path: Path) -> bool:
        """Route validation based on file extension."""
        suffix = dataset_path.suffix.lower()
        vector_exts = {".geojson", ".shp", ".gpkg", ".parquet", ".csv"}

        if suffix in vector_exts:
            return self.validate_vector(dataset_path)
        elif suffix in {".tif", ".tiff", ".nc", ".vrt"}:
            return self.validate_raster(dataset_path)
        else:
            raise ValueError(f"Unsupported format: {suffix}")

The control flow is a route-then-gate:

The validator routes by file extension, then both data paths share one CRS-equality gate The dataset path flows into a File extension diamond with three branches. The vector branch reaches validate_vector and the raster branch reaches validate_raster; the unsupported branch terminates in raise ValueError. validate_vector and validate_raster both feed a shared CRS equals expected diamond, whose yes branch returns True and whose no branch raises CRSValidationError. dataset path File extension? validate_vector() validate_raster() raise ValueError CRS equals expected? return True raise CRSValidationError vector raster unsupported yes no

Step 2 — Add the round-trip tolerance guard

For layers that are reprojected in-pipeline, assert the residual from the formula above stays bounded. This catches degenerate transformation graphs (missing grid shift files, no datum pipeline) that CRS.equals cannot see.

# roundtrip.py
from pyproj import Transformer


def roundtrip_residual_m(src_crs, dst_crs, x: float, y: float) -> float:
    """Forward+inverse transform residual in source-CRS units."""
    fwd = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
    inv = Transformer.from_crs(dst_crs, src_crs, always_xy=True)
    xb, yb = inv.transform(*fwd.transform(x, y))
    return ((x - xb) ** 2 + (y - yb) ** 2) ** 0.5

Step 3 — Gate it in GitHub Actions across a version matrix

Embed the validator as a dedicated stage. Running a Python/GDAL matrix surfaces the axis-order regressions described above before they reach a developer’s machine.

# .github/workflows/crs-validation.yml
name: CRS Validation Pipeline
on: [push, pull_request]

jobs:
  validate-crs:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]
    steps:
      - uses: actions/checkout@v5
      - name: Set up Python $NaN
        uses: actions/setup-python@v6
        with:
          python-version: $NaN
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install geopandas rasterio pyproj pytest
      - name: Run CRS Validation
        run: |
          python -c "
          from pathlib import Path
          from crs_validator import CRSValidator, CRSValidationError
          import logging
          logging.basicConfig(level=logging.INFO)

          validator = CRSValidator(expected_crs='EPSG:4326', axis_order='always_xy')
          test_files = [f for f in Path('data/').rglob('*') if f.is_file()]
          for f in test_files:
              try:
                  validator.run(f)
                  print(f'PASS: {f.name}')
              except (CRSValidationError, ValueError) as e:
                  print(f'FAIL: {f.name}: {e}')
                  raise SystemExit(1)
          "

For local development, wrap the validator in a pre-commit hook so CRS drift is caught before a push — shifting the check left and reducing CI queue congestion.

Verification Pattern

Prove the gate works with fixtures of known authority codes rather than production data. Synthetic fixtures here are exactly the use case described in mocking geospatial data for tests: generate GeoJSON and GeoTIFF with deliberate CRS configurations and assert both the pass and fail paths.

# test_crs_validator.py
import geopandas as gpd
import pytest
from shapely.geometry import Point

from crs_validator import CRSValidator, CRSValidationError


def test_rejects_wrong_authority(tmp_path):
    path = tmp_path / "wrong.geojson"
    gpd.GeoDataFrame(
        {"id": [1]}, geometry=[Point(0, 0)], crs="EPSG:3857"
    ).to_file(path, driver="GeoJSON")

    with pytest.raises(CRSValidationError, match="CRS mismatch"):
        CRSValidator("EPSG:4326").run(path)


def test_accepts_matching_authority(tmp_path):
    path = tmp_path / "ok.geojson"
    gpd.GeoDataFrame(
        {"id": [1]}, geometry=[Point(0, 0)], crs="EPSG:4326"
    ).to_file(path, driver="GeoJSON")

    assert CRSValidator("EPSG:4326").run(path) is True

Run it as a one-liner in CI or locally:

pytest test_crs_validator.py -q

Failure Modes and Edge Cases

  1. Anti-meridian wrap. A polygon spanning ±180° longitude in EPSG:4326 reprojects cleanly to EPSG:3857 numerically, but a naive bounds check will report a near-global extent. Validate the authority code here, never an inferred bounding box.
  2. Polar / authority axis order. Projected polar CRSs (for example EPSG:3413, NSIDC Sea Ice Polar Stereographic) and some geographic CRSs declare a north,east or lat,lon authority order. Without always_xy, coordinates silently swap — CRS.equals still passes while every transformed point is wrong. Assert axis_info[0].direction explicitly.
  3. Empty or absent CRS. A GeoJSON with no crs member is interpreted as EPSG:4326 by some readers and as “undefined” by others. The validator must raise on crs is None rather than assume a default, or you encode the ambiguity into production.
  4. Mixed Z/M coordinates. A 3D PointZ layer can carry a 2D horizontal CRS, leaving the vertical component undefined. Compound CRSs (EPSG:5773 geoid + horizontal datum) compare unequal to their 2D counterpart even when the horizontal definition matches — decide whether your contract demands the compound code or only the horizontal one.
  5. ESRI:102100 vs. EPSG:3857. These describe the same Web Mercator projection but resolve to different authority codes. Toggle allow_equivalent and compare with ignore_axis_order/ignore_coordinate_order semantics so legacy ArcGIS exports are not rejected outright.

Because not every layer needs identical enforcement, keep the strictness in config: web tile caches demand exact EPSG:3857 alignment, while analytical pipelines may accept EPSG:4326 with an explicit axis-order declaration. Never log raw coordinate arrays or bounding boxes in CI output — emit metadata hashes, EPSG codes, and assertion results only, in line with the security boundaries for spatial QA.

Which CRS check catches each edge case, and the symptom when it is missed A three-column table. The columns are CRS edge case, the check that catches it, and the failure symptom if missed. Rows cover anti-meridian wrap, polar or authority axis order, missing or null CRS, mixed Z and M coordinates, and ESRI:102100 versus EPSG:3857 Web Mercator. CRS edge case Check that catches it Symptom if missed Anti-meridian wrap Assert authority code, never an inferred bbox Bounds check reports a near-global extent Polar / authority axis order axis_info[0].direction == always_xy Coords silently swap; CRS.equals still passes Missing / null CRS raise on crs is None (no default assumed) Reader defaults to 4326; ambiguity reaches prod Mixed Z / M coords Compare compound vs horizontal CRS code Vertical undefined; 5773 unequal to its 2D code ESRI:102100 vs EPSG:3857 allow_equivalent + ignore_axis_order Valid legacy ArcGIS export rejected outright

Conclusion

Automating CRS validation in CI turns spatial data integrity from a reactive debugging exercise into a proactive engineering control: a deterministic, structural assertion that runs on every commit and refuses silent geometric corruption before it propagates. Treat it as a non-negotiable contract and revisit the parent scoping rules for map data validation to decide how strict that contract should be per layer.