Continuous Integration for Spatial Validation
Spatial datasets change through the same mechanism as source code: someone edits features, commits, and opens a merge request. Yet most teams still validate geometry, schema, and coordinate reference system (CRS) correctness manually — or not at all — long after the change has merged. Embedding validation into continuous integration and continuous delivery (CI/CD) closes that gap by turning every proposed data change into a build that either passes or fails against explicit quality rules. This guide, part of the broader Validation Pipeline Architecture, shows GIS analysts, QA engineers, and platform teams how to make spatial validation a hard merge gate rather than an afterthought.
Prerequisites
Confirm each item before wiring validation into your build. A CI job that runs a different geometry engine than your developers is worse than no job at all — it produces failures nobody can reproduce.
-
A version-controlled data repository. Spatial data lives in Git (as GeoJSON, GeoPackage, or as extract scripts pointing at a database). CI can only gate changes it can see as commits and pull requests.
-
A pinned geospatial toolchain in a container. Build an image on
python:3.12-slimwithgdal==3.8.*,geos3.12,proj9.3,geopandas==0.14.*,shapely==2.0.*, andpyproj==3.6.*. Reference it by digest. The Docker-Based PostGIS Validation Containers guide covers building the database side of this toolchain. -
A validation rule set expressed as code. Reuse the rule functions from Building Rule Engines with GeoPandas so the same checks run interactively and in CI. Do not re-implement validation logic inside the CI configuration.
-
Committed test fixtures. At minimum, one known-valid feature and one deliberately broken feature per rule class (geometry, schema, CRS). These make the validation suite self-testing.
-
Documented quality policies. The pass/fail thresholds a build enforces must trace back to written rules. Defining Spatial Data Quality Policies is the source of truth for which violations are blockers versus warnings.
Conceptual Foundation
A CI merge gate for spatial data rests on a single principle: a data change is a build, and an invalid geometry is a compilation error. The moment you accept that framing, the rest follows from established software delivery practice. A pull request triggers a job; the job runs in a reproducible environment; the job exits non-zero when quality rules are violated; and branch protection prevents merging until the job exits zero.
Three properties distinguish spatial validation in CI from ordinary unit testing.
The environment is the geometry engine. In application code, a test either passes or fails deterministically given the source. In spatial validation, the toolchain version determines the answer. GEOS 3.11 and 3.12 can disagree on whether a near-degenerate ring is valid; a missing PROJ datum grid changes reprojection output. Reproducibility therefore requires pinning the native libraries — GDAL (Geospatial Data Abstraction Library), GEOS, and PROJ — not just the Python packages. This is why the containerized toolchain is non-negotiable.
The input is data, not just code. A spatial validation build asserts things about committed feature collections and about datasets pulled from external storage. Fixtures committed to the repository make the validator itself testable — if your bowtie fixture stops failing, your validity check has regressed. Production extracts, being large and changeable, are validated in scheduled jobs rather than on the pull request path.
Failure must be legible to a reviewer. A build that fails with a stack trace forces the author to read logs. A build that fails with “feature parcel_4471 violated no_self_intersection: Self-intersection at (404732.5, 6789234.1)” tells them exactly what to fix. Machine-readable artifacts — JUnit XML plus a JSON sidecar — turn raw failures into reviewable annotations. Classifying those failures by severity, as covered in Categorizing and Prioritizing Spatial Errors, decides which ones actually block the merge and which only warn.
The three implementation surfaces this guide develops in depth are: running the validation job on a hosted CI provider (see Running Spatial Validation in GitHub Actions), catching violations before they ever reach CI with Pre-Commit Hooks for GeoJSON Schema Checks, and provisioning an ephemeral spatial database with Docker-Based PostGIS Validation Containers.
Step-by-Step Implementation
Step 1: Express Validation as Exit-Code-Bearing Tests
CI runners interpret a non-zero exit code as a failed build. The cleanest way to produce that signal from spatial checks is to wrap them in pytest, which exits non-zero when any assertion fails and can emit JUnit XML natively.
# tests/test_spatial_validation.py
import geopandas as gpd
import shapely
import pytest
DATASETS = ["fixtures/parcels.geojson", "fixtures/roads.geojson"]
@pytest.fixture(params=DATASETS)
def layer(request):
gdf = gpd.read_file(request.param)
gdf.attrs["source"] = request.param
return gdf
def test_crs_is_declared(layer):
assert layer.crs is not None, f"{layer.attrs['source']}: CRS is undefined"
assert layer.crs.to_epsg() == 4326, (
f"{layer.attrs['source']}: expected EPSG:4326, got EPSG:{layer.crs.to_epsg()}"
)
def test_geometries_are_valid(layer):
invalid = layer[~shapely.is_valid(layer.geometry.values)]
reasons = shapely.is_valid_reason(invalid.geometry.values)
detail = list(zip(invalid.index.tolist(), reasons))
assert invalid.empty, f"{layer.attrs['source']}: invalid geometries: {detail}"
def test_no_null_geometries(layer):
null_ids = layer[layer.geometry.isna()].index.tolist()
assert not null_ids, f"{layer.attrs['source']}: null geometries at {null_ids}"
Verification: run pytest -q tests/test_spatial_validation.py locally. On clean fixtures the command exits 0; introduce a self-intersecting polygon into parcels.geojson and confirm the run exits non-zero and names the offending feature index.
Step 2: Add a Schema Contract Check
Geometry validity is necessary but not sufficient. Attribute schema drift — a renamed column, a null in a mandatory field, an out-of-range code — is just as damaging. Validate the attribute contract in the same suite so schema and geometry fail through one gate.
# tests/test_schema_contract.py
import geopandas as gpd
import pandas as pd
import pytest
REQUIRED = {
"fixtures/parcels.geojson": {
"parcel_id": "object",
"zoning_code": "object",
"assessed_value": "float64",
},
}
@pytest.mark.parametrize("path,contract", REQUIRED.items())
def test_required_columns_present(path, contract):
gdf = gpd.read_file(path)
missing = [c for c in contract if c not in gdf.columns]
assert not missing, f"{path}: missing mandatory columns {missing}"
@pytest.mark.parametrize("path,contract", REQUIRED.items())
def test_mandatory_fields_not_null(path, contract):
gdf = gpd.read_file(path)
offenders = {
col: gdf[gdf[col].isna()].index.tolist()
for col in contract if col in gdf.columns and gdf[col].isna().any()
}
assert not offenders, f"{path}: nulls in mandatory fields {offenders}"
Verification: delete the zoning_code value from one fixture feature and confirm test_mandatory_fields_not_null fails with that feature’s index in the message. The schema contract mirrors the constraint mapping discussed in the core standards material on attribute schemas.
Step 3: Emit a Machine-Readable Report
A build should leave behind more than a log. Emit JUnit XML for the CI dashboard and a JSON sidecar keyed by feature identifier so reviewers and downstream tooling can consume results without parsing prose.
# validation/report.py
import json
import geopandas as gpd
import shapely
from pathlib import Path
def build_report(paths: list[str], out: str = "reports/validation.json") -> int:
findings = []
for path in paths:
gdf = gpd.read_file(path)
valid = shapely.is_valid(gdf.geometry.values)
reasons = shapely.is_valid_reason(gdf.geometry.values)
for idx, ok, reason in zip(gdf.index, valid, reasons):
if not ok:
findings.append({
"dataset": path,
"feature_id": str(idx),
"rule": "geometry_valid",
"reason": reason,
"severity": "blocker",
})
Path(out).parent.mkdir(parents=True, exist_ok=True)
Path(out).write_text(json.dumps({"findings": findings}, indent=2))
return len(findings)
if __name__ == "__main__":
import sys
n = build_report(sys.argv[1:] or ["fixtures/parcels.geojson"])
print(f"{n} finding(s) written to reports/validation.json")
raise SystemExit(1 if n else 0)
Verification: run python -m validation.report fixtures/parcels.geojson, then inspect reports/validation.json. On clean data the file contains an empty findings array and the process exits 0; on broken data every offending feature appears with a reason and the process exits 1.
Step 4: Wire the Job into a Required Merge Gate
Run the suite inside the pinned container on every pull request and generate the JUnit report the CI dashboard renders. The concrete workflow file is developed in Running Spatial Validation in GitHub Actions; the command the runner ultimately invokes is the same one you run locally.
# scripts/ci_entrypoint.py — invoked as the container command in CI
import subprocess
import sys
def main() -> int:
# pytest exits non-zero on any failed assertion; --junitxml feeds the dashboard
result = subprocess.run(
[
"pytest", "-q", "tests/",
"--junitxml=reports/junit.xml",
],
check=False,
)
# Also produce the JSON sidecar regardless of pytest outcome
subprocess.run([sys.executable, "-m", "validation.report",
"fixtures/parcels.geojson", "fixtures/roads.geojson"],
check=False)
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())
Verification: open a pull request that adds an invalid geometry. The status check must appear as failed and, with branch protection configured to require it, the merge button must be disabled until a follow-up commit fixes the geometry.
Common Failure Modes & Fixes
| Symptom in CI | Root cause | Remediation |
|---|---|---|
| Build passes locally, fails in CI (or vice versa) on the same file | Different GEOS/PROJ versions between laptop and runner | Pin the toolchain by container digest; never rely on the runner’s preinstalled GDAL |
CRSError / NaN coordinates after reprojection only in CI |
PROJ datum grids absent from the minimal image | Install proj-data in the image or set PROJ_NETWORK=ON; verify pyproj.datadir.get_data_dir() in a smoke test |
| Validation job never blocks merges despite failing | Status check not marked required in branch protection | Add the job’s context to the required checks list on the protected branch |
| Job times out on a large production extract | Whole-dataset topology check on the pull request path | Move heavy checks to a scheduled job; keep the pull request path to fixtures and diffs |
fiona.errors.DriverError: unsupported driver |
GeoPackage or FlatGeobuf driver not compiled into the CI GDAL | Rebuild the image with the required GDAL drivers enabled; assert driver availability in a startup check |
| Report artifact missing when the build fails early | Report step skipped because a prior step errored | Run the report generation with if: always() (or an unconditional step) so artifacts publish even on failure |
| Flaky failures on features near the anti-meridian | Reprojection wrap-around differs by PROJ version | Normalize to a projected CRS before validation; pin PROJ and document the tolerance |
Performance & Scale Considerations
Split the fast path from the slow path. Structural, per-feature checks — validity, null geometry, CRS presence, schema conformance — are cheap and should run on every push so authors get feedback in under a minute. Whole-dataset checks — overlap detection across every parcel, network connectivity, gap analysis — are quadratic in the worst case and belong in scheduled nightly runs, not on the merge gate. Gating on a job that takes twenty minutes trains developers to bypass it.
Cache the toolchain, not the data. The dominant cost in a spatial CI job is usually pulling and initializing the geospatial image and its wheels. Cache the pip/uv download layer and pull the image by digest so the layer cache stays warm. Do not cache the datasets under test — stale cached fixtures produce false passes.
Validate the diff, not the world, where you can. For large committed layers, restrict per-pull-request validation to features touched by the change. A GeoJSON diff exposes changed feature identifiers; load only those into the validator. Full-layer validation then runs on merge to the main branch and on schedule.
Parallelize across datasets with a matrix. When a repository holds many independent layers, a build matrix runs one validation shard per layer in parallel, cutting wall-clock time and isolating failures to a single dataset. The matrix pattern is developed concretely in the GitHub Actions guide.
Push checks earlier to save CI minutes. Every violation caught by a pre-commit hook is one that never consumes a CI runner. Local hooks and CI form a defense in depth: hooks catch the obvious and cheap, CI catches what hooks cannot (cross-file and containerized database checks).
Integration with the Validation Pipeline
CI is the delivery surface of the validation pipeline — the place where the rule engine, the severity model, and the governance policy converge into a single pass/fail decision on a proposed change. The rules themselves come from Building Rule Engines with GeoPandas; CI is merely the runner that executes them against committed data and interprets the result as a build outcome.
Upstream contract. The CI job expects the same normalized inputs the rule engine expects: a declared CRS, a stable attribute schema, and geometry in a driver the pinned GDAL supports. Anything that fails those preconditions should fail loudly at job start with a clear message, not deep inside a validation assertion.
Severity contract. Not every finding blocks a merge. A build maps blocker-severity findings to non-zero exit codes and warning-severity findings to annotations that inform but do not fail. That mapping is owned by Categorizing and Prioritizing Spatial Errors, and CI simply honors it — read severity from the rule set rather than hard-coding thresholds in the workflow.
Governance contract. The threshold values a build enforces are policy decisions, not engineering conveniences. When Defining Spatial Data Quality Policies declares that assessed-value nulls are a blocker for cadastral layers, the CI gate is where that declaration becomes enforceable. This traceability — from written policy, through a coded rule, to a failing build annotated with the offending feature — is what makes automated spatial validation auditable rather than merely automated.
Deployment contract. A passing gate is a precondition for publishing data downstream. In a continuous delivery setup, a merge to the main branch after a green validation build can trigger the promotion of the dataset to a serving tier, closing the loop from a proposed edit to a validated, deployed layer.
Frequently Asked Questions
Should spatial validation run on every commit or only on pull requests?
Run fast structural checks — geometry validity, schema conformance, CRS presence — on every push so authors get immediate feedback. Reserve expensive whole-dataset topology and cross-layer checks for pull request runs and scheduled nightly builds. Making the pull request job a required status check is what turns validation into a genuine merge gate; per-commit runs are advisory and exist to shorten the feedback loop, not to block.
How do I keep CI validation results reproducible across machines?
Pin the entire geospatial toolchain in a container image: GDAL, GEOS, PROJ, and the PROJ datum grids all affect geometry and coordinate reference system results. A GEOS minor-version difference can change an is_valid outcome on a degenerate polygon, and a missing PROJ grid silently produces NaN coordinates after reprojection. Reference the image by digest, not a floating tag, so the same binaries run on a developer laptop and on the CI runner.
What belongs in a spatial test fixture committed to the repository?
Small, human-readable files that encode known outcomes: a valid polygon, a self-intersecting bowtie, a feature with a missing mandatory attribute, and a feature in the wrong CRS. Keep each fixture under a few kilobytes so diffs are reviewable, store them as GeoJSON where possible for line-level version control, and name them so the expected failure is obvious. Large production extracts do not belong in the repository — reference them from object storage and validate them in a separate scheduled job.
How should a failing spatial validation build report which features are wrong?
Emit a structured report — JUnit XML for the CI dashboard plus a JSON sidecar keyed by feature identifier, rule name, and failure reason — and upload it as a build artifact. Annotate the pull request with the failing feature count and the first few offending identifiers so a reviewer can triage without opening the raw log. Avoid dumping full geometries into the log; reference feature identifiers and attach the report instead.
Related
- Running Spatial Validation in GitHub Actions — the concrete workflow YAML, matrix, caching, and PR annotations
- Pre-Commit Hooks for GeoJSON Schema Checks — catching violations locally before they reach CI
- Docker-Based PostGIS Validation Containers — the ephemeral spatial database that backs containerized checks
- Building Rule Engines with GeoPandas — the rule functions the CI job executes
- Categorizing and Prioritizing Spatial Errors — the severity model that decides which findings block a merge
- Defining Spatial Data Quality Policies — the policies whose thresholds the gate enforces
Back to Validation Pipeline Architecture