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

  1. Python 3.11+ with a pinned, reproducible environment. The GEOS version compiled into shapely determines validity semantics, so pin it: shapely==2.0.4, geopandas==1.0.1, pyogrio==0.9.0, numpy==2.0.1.
  2. A GEOS build ≥ 3.11. shapely.make_valid uses the GEOSMakeValid entry point; older GEOS lacks the coverage-friendly structure method. Verify with python -c "import shapely; print(shapely.geos_version)".
  3. 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).
  4. A CI runner (GitHub Actions ubuntu-24.04 or equivalent) with network access to install wheels. shapely, geopandas, and pyogrio ship manylinux wheels bundling GEOS and GDAL, so no system GDAL is required.
  5. Environment variablesGEOM_VALIDATION_TOLERANCE (area threshold below which an overlap is treated as a rounding sliver, e.g. 1e-9) and COVERAGE_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"

Geometry and topology validation data-flow A flowchart: changed vector files enter a reader, each feature is tested with shapely is_valid; valid geometries continue to a coverage topology stage that checks a polygon layer for gaps and overlaps; invalid geometries and coverage failures route to a blocking exit, while clean geometry routes to a passing exit. Read changed files (pyogrio) is_valid? per feature no — explain_validity Block merge exit 1 yes Coverage topology gaps + overlaps via spatial index clean? yes Allow merge exit 0 gap/overlap → block

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.clip and shapely intersection against a mask frequently produce GeometryCollection outputs 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 the simplification_tolerance in 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