Automated Topology & Geometry Validation
Invalid geometry is the quietest failure mode in a spatial pipeline: a self-intersecting parcel or an unclosed ring passes a file-format check, renders acceptably on a map, and only surfaces when a spatial join returns nonsense areas or a downstream ST_Union throws deep inside a database. Automated topology and geometry validation moves that detection to the earliest possible point — the pull request — by running deterministic geometric checks on every changed feature before it can merge. This guide is part of CI/CD Validation & Policy Enforcement for Spatial Data, the parent framework that governs when a spatial check blocks a merge versus emits a warning, and it focuses specifically on geometric integrity rather than metadata or licensing.
The audience is GIS data managers, open-source maintainers, and Python automation engineers who maintain versioned vector datasets — parcel fabrics, administrative boundaries, hydrography, land-use coverages — where a malformed polygon has legal or analytical consequences. By the end you will have a runnable validator built on shapely, geopandas, and pyogrio that classifies each failure, distinguishes a genuinely invalid geometry from a valid-but-suspect one, and exits non-zero so the continuous integration job fails the PR.
Prerequisites
- Python 3.11+ with a pinned, reproducible environment. The GEOS version compiled into
shapelydetermines validity semantics, so pin it:shapely==2.0.4,geopandas==1.0.1,pyogrio==0.9.0,numpy==2.0.1. - A GEOS build ≥ 3.11.
shapely.make_validuses theGEOSMakeValidentry point; older GEOS lacks the coverage-friendlystructuremethod. Verify withpython -c "import shapely; print(shapely.geos_version)". - Version-controlled spatial files. Vector data tracked through Git LFS or DVC so the CI runner can retrieve only the changed files (see the parent framework for the checkout stage).
- A CI runner (GitHub Actions
ubuntu-24.04or equivalent) with network access to install wheels.shapely,geopandas, andpyogrioship manylinux wheels bundling GEOS and GDAL, so no system GDAL is required. - Environment variables —
GEOM_VALIDATION_TOLERANCE(area threshold below which an overlap is treated as a rounding sliver, e.g.1e-9) andCOVERAGE_LAYERS(comma-separated layer names that must form a gap-free polygon coverage).
Install the toolchain into the runner or a local virtual environment:
python -m pip install \
"shapely==2.0.4" \
"geopandas==1.0.1" \
"pyogrio==0.9.0" \
"numpy==2.0.1"
Concept & Spec Reference
Geometric validity is defined normatively by OGC Simple Features (ISO 19125-1) and its access model ISO 19107. A geometry is valid when it satisfies the assertions for its type: a polygon’s rings must be closed and simple (non-self-intersecting), interior rings must lie inside the exterior and touch it at most at single points, and the interior must be a connected point set. shapely, backed by GEOS, implements exactly these assertions, which is why it is the reference tool for geometry checks in a CI pipeline. This complements structural checks covered in spatial data schema linting in CI, which verify column presence and CRS but not geometric correctness.
It is essential to separate two distinct question classes. Object validity asks whether a single geometry is self-consistent (is_valid). Coverage topology asks whether a set of geometries relate correctly to one another — no gaps and no overlaps across an administrative tiling, for example. A layer can consist entirely of valid polygons that nonetheless overlap; both checks are needed.
| Validity / topology rule | Check | Library call |
|---|---|---|
| Self-intersecting ring (bowtie) | Object validity | shapely.is_valid(geom) → False; reason via shapely.validation.explain_validity(geom) |
| Unclosed ring | Object validity | shapely.is_valid(geom); GEOS auto-closes on read, so also assert ring.is_ring on raw coordinates |
| Interior ring outside exterior | Object validity | explain_validity(geom) reports Hole lies outside shell |
| Duplicate / degenerate vertices | Object validity | shapely.is_valid(geom); zero-length segments flagged as Too few points in geometry component |
| Null or empty geometry | Presence | geom is None or shapely.is_empty(geom) |
| Non-noded / repaired shape | Repair preview | shapely.make_valid(geom) then compare geom.equals(repaired) |
| Overlaps in polygon coverage | Coverage topology | pairwise a.intersection(b).area > tol over STRtree candidates |
| Gaps in polygon coverage | Coverage topology | shapely.union_all(geoms) then difference against declared extent |
| Sliver polygon | Quality heuristic | geom.area < min_area or thinness 4*pi*area / perimeter**2 < t |
| Wrong CRS for area checks | Precondition | gdf.crs.is_geographic — reproject to an equal-area CRS before area math |
The explain_validity function is the workhorse for reporting: instead of a bare False, it returns a human-readable reason and the coordinate at which GEOS detected the problem, for example Self-intersection[537402.1 182004.7]. That coordinate is what lets a contributor open the file in QGIS and jump straight to the offending vertex.
Implementation Walkthrough
Step 1 — Read only the changed features
Rationale: validating an entire multi-gigabyte fabric on every PR is wasteful; the pipeline should read just the files in the diff. pyogrio reads vector data into a geopandas.GeoDataFrame far faster than the legacy Fiona path because it uses GDAL’s columnar API.
from pathlib import Path
import geopandas as gpd
def load_layer(path: Path) -> gpd.GeoDataFrame:
"""Read a vector file into a GeoDataFrame using the pyogrio engine."""
gdf = gpd.read_file(path, engine="pyogrio")
if gdf.crs is None:
raise ValueError(f"{path}: layer has no CRS; cannot validate reliably")
return gdf
Step 2 — Classify each geometry’s validity
Rationale: a single boolean is not actionable. Use explain_validity so each failure carries a reason and a coordinate. Empty and null geometries are handled separately because GEOS treats an empty geometry as valid — which is almost never what a data steward wants in a production layer.
import shapely
from shapely.validation import explain_validity
def classify_geometry(idx, geom) -> dict | None:
"""Return a violation dict for one feature, or None if it is acceptable."""
if geom is None:
return {"fid": idx, "rule": "null_geometry", "detail": "geometry is None"}
if shapely.is_empty(geom):
return {"fid": idx, "rule": "empty_geometry", "detail": "geometry is empty"}
if not shapely.is_valid(geom):
return {"fid": idx, "rule": "invalid_geometry", "detail": explain_validity(geom)}
return None
def check_object_validity(gdf) -> list[dict]:
"""Scan every feature and collect object-level validity violations."""
violations = []
for idx, geom in zip(gdf.index, gdf.geometry):
result = classify_geometry(idx, geom)
if result is not None:
violations.append(result)
return violations
Step 3 — Preview what a repair would change
Rationale: make_valid is the right tool for a deliberate repair, but running it blindly hides the fact that it alters topology. Previewing the difference lets the pipeline report “this feature is invalid and repairing it would change its area by 3.2 m²” so a reviewer can judge whether the fix is safe.
import shapely
def preview_repair(geom) -> dict:
"""Compare a geometry against its make_valid repair without mutating data."""
repaired = shapely.make_valid(geom)
return {
"changed_type": geom.geom_type != repaired.geom_type,
"area_delta": abs(geom.area - repaired.area),
"repaired_wkt": repaired.wkt[:80],
}
Step 4 — Test polygon coverage for gaps and overlaps
Rationale: an administrative coverage — census tracts, zoning districts, watershed boundaries — must tile its extent without gaps or overlaps. Comparing pairs naively is O(n²); an STRtree spatial index restricts comparisons to polygons whose bounding boxes actually intersect. Area math must run in a projected, equal-area CRS, so the function guards against geographic coordinates.
import shapely
from shapely import STRtree
def check_coverage_topology(gdf, area_tolerance: float) -> list[dict]:
"""Detect overlaps (and a coarse gap signal) in a polygon coverage."""
if gdf.crs is not None and gdf.crs.is_geographic:
raise ValueError("coverage checks need a projected CRS; reproject first")
geoms = list(gdf.geometry)
tree = STRtree(geoms)
violations = []
seen: set[tuple[int, int]] = set()
for i, geom in enumerate(geoms):
for j in tree.query(geom):
if i == j or (min(i, j), max(i, j)) in seen:
continue
seen.add((min(i, j), max(i, j)))
overlap = geom.intersection(geoms[j])
if not overlap.is_empty and overlap.area > area_tolerance:
violations.append({
"rule": "polygon_overlap",
"fids": [int(gdf.index[i]), int(gdf.index[j])],
"overlap_area": round(overlap.area, 4),
})
# Coarse gap signal: unioned area vs. summed area reveals double-counting or holes.
merged = shapely.union_all(geoms)
summed = float(sum(g.area for g in geoms))
if abs(summed - merged.area) > area_tolerance:
violations.append({
"rule": "coverage_area_mismatch",
"summed_area": round(summed, 4),
"merged_area": round(merged.area, 4),
})
return violations
Validation & CI Integration
The functions above compose into a single runner that reads changed files, aggregates violations, and returns an exit code. Wrap it so the CI system can fail the job on any violation.
import json
import sys
from pathlib import Path
def validate_paths(paths: list[Path], coverage_layers: set[str], tol: float) -> dict:
"""Run object and coverage checks; return a structured report."""
report = {"files": {}, "overall": "pass"}
for path in paths:
gdf = load_layer(path)
issues = check_object_validity(gdf)
if path.stem in coverage_layers and issues == []:
issues += check_coverage_topology(gdf.to_crs(gdf.estimate_utm_crs()), tol)
report["files"][str(path)] = issues
if issues:
report["overall"] = "fail"
return report
if __name__ == "__main__":
tolerance = float(__import__("os").environ.get("GEOM_VALIDATION_TOLERANCE", "1e-9"))
layers = set(__import__("os").environ.get("COVERAGE_LAYERS", "").split(","))
targets = [Path(p) for p in sys.argv[1:]]
result = validate_paths(targets, layers, tolerance)
print(json.dumps(result, indent=2))
sys.exit(0 if result["overall"] == "pass" else 1)
Wire the runner into a GitHub Actions job that triggers only on changes to vector files, so geometry validation runs when — and only when — spatial data changes:
name: Geometry Validation
on:
pull_request:
paths:
- "data/**/*.gpkg"
- "data/**/*.geojson"
- "data/**/*.shp"
- "data/**/*.fgb"
jobs:
validate-geometry:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install geometry toolchain
run: pip install shapely==2.0.4 geopandas==1.0.1 pyogrio==0.9.0 numpy==2.0.1
- name: Collect changed spatial files
id: diff
run: |
git diff --name-only --diff-filter=ACMRT origin/${{ github.base_ref }} HEAD \
| grep -E '\.(gpkg|geojson|shp|fgb)$' > changed.txt || true
echo "count=$(wc -l < changed.txt)" >> "$GITHUB_OUTPUT"
- name: Validate geometry
if: steps.diff.outputs.count != '0'
env:
GEOM_VALIDATION_TOLERANCE: "1e-9"
COVERAGE_LAYERS: "zoning_districts,census_tracts"
run: xargs -a changed.txt python scripts/validate_geometry.py
Lock the behaviour in with a pytest regression suite so a future refactor cannot silently loosen the validity rules. Building small WKT fixtures keeps the tests independent of any data file:
import shapely
from scripts.validate_geometry import classify_geometry
def test_bowtie_is_flagged():
bowtie = shapely.from_wkt("POLYGON ((0 0, 1 1, 1 0, 0 1, 0 0))")
result = classify_geometry(0, bowtie)
assert result is not None
assert result["rule"] == "invalid_geometry"
assert "Self-intersection" in result["detail"]
def test_clean_square_passes():
square = shapely.from_wkt("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))")
assert classify_geometry(0, square) is None
For teams that also want geometry checks to run before a commit is even created, the same classify_geometry function can be invoked from a lightweight pre-commit hook for spatial metadata, reserving the heavier coverage checks for the CI runner.
Derivative & Lineage Management
Most invalid geometry in a governed repository is not authored by hand — it is introduced by a processing step. Recognising which operations create invalidity tells you where to place a re-validation checkpoint.
- Reprojection. Transforming to a new CRS with
gdf.to_crs(...)re-computes every coordinate. A polygon that was valid at high precision in a projected CRS can develop a micro self-intersection near a pinch point once coordinates are rounded into a geographic CRS. Always re-validate after a reprojection, not only at ingest. - Clipping.
geopandas.clipandshapelyintersection against a mask frequently produceGeometryCollectionoutputs mixing polygons with stray lines or points along the cut edge. Downstream code expecting pure polygons then fails. Filter the collection to the intended dimension and re-run validity. - Simplification. Douglas–Peucker (
geom.simplify) can pull a vertex across an adjacent edge, converting a valid polygon into a bowtie, and can open gaps in a previously seamless coverage. Record thesimplification_tolerancein lineage metadata and re-check coverage topology after simplifying. - Dissolve and union. Aggregating features by attribute can leave slivers where source boundaries did not align to the same vertices. A post-dissolve coverage check catches these before they propagate.
Every repair that a pipeline applies deliberately — a reviewed make_valid pass — should be captured as a lineage record so a later audit can reconstruct exactly what changed and why. This connects directly to the derivative-tracking practices in policy enforcement gates for data PRs, where CRS transformations and simplification tolerances are enforced as declared metadata.
Pitfalls & Resolution Table
| Pitfall | Root Cause | Resolution Strategy |
|---|---|---|
| Empty geometries pass the gate | GEOS classifies an empty geometry as valid, so is_valid returns True |
Test shapely.is_empty(geom) and geom is None explicitly before the is_valid check and treat both as violations |
| Overlap area computed as zero on lat/lon data | Area arithmetic in a geographic CRS (degrees) yields meaningless tiny numbers, so real overlaps fall below tolerance | Reproject to an equal-area or local UTM CRS with gdf.estimate_utm_crs() before any area comparison |
| Coverage check times out on large layers | Naive pairwise O(n²) intersection over tens of thousands of polygons | Build an STRtree and only compare candidates returned by tree.query(geom); deduplicate ordered pairs |
make_valid silently changes reported areas |
A blind repair splits bowties or drops slivers, altering area and feature type | Never auto-fix in the gate; use preview_repair to report the area delta and require a human-approved repair commit |
| Unclosed rings are not detected | GEOS auto-closes rings when parsing WKB/WKT, masking the source defect | Inspect raw coordinate arrays with LinearRing.is_ring before handing geometry to GEOS, or validate at the source format layer |
| Validity differs between developer laptop and CI | Different bundled GEOS versions apply different validity semantics | Pin shapely exactly and assert shapely.geos_version at job start so a mismatch fails loudly instead of producing divergent results |
| Multi-part features report only the first error | explain_validity returns a single reason for a MultiPolygon |
Iterate geom.geoms and validate each part, aggregating per-part reasons and coordinates |
| Precision-model rounding reintroduces invalidity | Writing to a format with limited coordinate precision (e.g. reduced GeoJSON precision) re-snaps vertices | Apply shapely.set_precision with the target grid size and re-validate before writing, so the on-disk file matches what was checked |
Related
- Detecting Invalid Geometries with Shapely in CI — a focused, self-contained script that scans a dataset and exits non-zero on the first invalid feature
- Policy Enforcement Gates for Data PRs — combine geometry results with license and metadata checks into a single merge decision
- Spatial Data Schema Linting in CI — the structural checks (columns, CRS codes, geometry types) that run alongside these geometric checks
- CI/CD Validation & Policy Enforcement for Spatial Data — the parent framework covering the full validation pipeline and its enforcement model