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.
- PROJ 6+ axis-order reversal. Modern
pyprojand GDAL default toaxis_order=always_xyfor geographic CRSs, whereas legacy pipelines assumedlat,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. - 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,
pyprojcannot guarantee equivalence to the original EPSG definition, causing assertion mismatches during spatial joins. - Lazy GDAL evaluation. Libraries like
rasterioandgeopandasdefer 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. - Deprecated EPSG codes. The EPSG Geodetic Parameter Registry periodically retires or redefines codes (for example
EPSG:3857vs.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:102100 ≡ EPSG: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:
where
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:
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
- Anti-meridian wrap. A polygon spanning ±180° longitude in
EPSG:4326reprojects cleanly toEPSG:3857numerically, but a naive bounds check will report a near-global extent. Validate the authority code here, never an inferred bounding box. - Polar / authority axis order. Projected polar CRSs (for example
EPSG:3413, NSIDC Sea Ice Polar Stereographic) and some geographic CRSs declare anorth,eastorlat,lonauthority order. Withoutalways_xy, coordinates silently swap —CRS.equalsstill passes while every transformed point is wrong. Assertaxis_info[0].directionexplicitly. - Empty or absent CRS. A GeoJSON with no
crsmember is interpreted asEPSG:4326by some readers and as “undefined” by others. The validator must raise oncrs is Nonerather than assume a default, or you encode the ambiguity into production. - Mixed Z/M coordinates. A 3D
PointZlayer can carry a 2D horizontal CRS, leaving the vertical component undefined. Compound CRSs (EPSG:5773geoid + 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. ESRI:102100vs.EPSG:3857. These describe the same Web Mercator projection but resolve to different authority codes. Toggleallow_equivalentand compare withignore_axis_order/ignore_coordinate_ordersemantics 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.
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.