Detecting Invalid Geometries with Shapely in CI
To detect invalid geometries in CI, read each changed vector file into a geopandas GeoDataFrame and test every feature with shapely.is_valid, reporting the reason with explain_validity and exiting with a non-zero status so the continuous integration job fails the pull request. That single pattern — scan, classify, exit — is all a merge gate needs to stop self-intersecting polygons and other malformed shapes from reaching a production catalog.
This guide is the focused, copy-ready implementation of one check within Automated Topology & Geometry Validation, the parent guide that also covers coverage topology (gaps and overlaps) and repair previews. It sits inside the broader CI/CD Validation & Policy Enforcement for Spatial Data framework, which decides when a failing geometry check blocks a merge versus merely warns. Here the scope is deliberately narrow: object-level validity of individual geometries, done well.
Automated Python Implementation
The script below is self-contained. Point it at one or more vector files (GeoPackage, GeoJSON, Shapefile, FlatGeobuf) and it prints a report of every invalid feature and returns exit code 1 if any are found. It relies only on geopandas and shapely — both ship manylinux wheels with GEOS bundled, so no system GDAL install is required on the runner.
#!/usr/bin/env python3
"""Detect invalid geometries in vector datasets for CI gating.
Usage:
python detect_invalid_geometries.py data/parcels.gpkg data/zoning.geojson
Exit codes:
0 — every geometry in every file is valid and non-empty
1 — at least one invalid, empty, or null geometry was found
2 — a file could not be read
"""
import sys
from pathlib import Path
import geopandas as gpd
import shapely
from shapely.validation import explain_validity
def scan_file(path: Path) -> list[dict]:
"""Return a list of violation records for one vector file."""
gdf = gpd.read_file(path, engine="pyogrio")
violations: list[dict] = []
for fid, geom in zip(gdf.index, gdf.geometry):
if geom is None:
violations.append({"fid": int(fid), "reason": "null geometry", "at": None})
continue
if shapely.is_empty(geom):
violations.append({"fid": int(fid), "reason": "empty geometry", "at": None})
continue
if not shapely.is_valid(geom):
reason = explain_validity(geom)
# explain_validity returns e.g. "Self-intersection[537402.1 182004.7]"
at = None
if "[" in reason and reason.endswith("]"):
at = reason[reason.index("[") + 1: -1]
violations.append({"fid": int(fid), "reason": reason, "at": at})
return violations
def report(path: Path, violations: list[dict]) -> None:
"""Print a human-readable report for one file."""
if not violations:
print(f"OK {path} — all geometries valid")
return
print(f"FAIL {path} — {len(violations)} invalid geometr(ies):")
for v in violations:
location = f" at {v['at']}" if v["at"] else ""
print(f" feature {v['fid']}: {v['reason']}{location}")
def main(argv: list[str]) -> int:
paths = [Path(p) for p in argv[1:]]
if not paths:
print("usage: detect_invalid_geometries.py FILE [FILE ...]")
return 2
total_invalid = 0
for path in paths:
if not path.exists():
print(f"ERROR file not found: {path}")
return 2
try:
violations = scan_file(path)
except Exception as exc: # noqa: BLE001 — surface any read failure as exit 2
print(f"ERROR could not read {path}: {exc}")
return 2
report(path, violations)
total_invalid += len(violations)
print(f"\nScanned {len(paths)} file(s); {total_invalid} invalid geometr(ies) total.")
return 1 if total_invalid else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
Two design choices make this CI-friendly. First, it collects all violations in one pass instead of aborting on the first, so a contributor sees the complete list of what to fix. Second, it distinguishes three outcomes through exit codes — clean (0), invalid data (1), and operational error such as an unreadable file (2) — so a broken runner is never mistaken for clean data.
Validation and pipeline integration
Run the script locally against a file you know is clean and one you know is broken before trusting it in CI. A quick way to manufacture a broken fixture is a bowtie polygon written with shapely:
# Install the pinned toolchain
pip install shapely==2.0.4 geopandas==1.0.1 pyogrio==0.9.0
# Create a deliberately invalid fixture, then scan it
python - <<'PY'
import geopandas as gpd
import shapely
bowtie = shapely.from_wkt("POLYGON ((0 0, 1 1, 1 0, 0 1, 0 0))")
gpd.GeoDataFrame(geometry=[bowtie], crs="EPSG:27700").to_file("bad.gpkg")
PY
python detect_invalid_geometries.py bad.gpkg
echo "exit code: $?" # expect 1
Lock the behaviour into a pytest case so a refactor cannot loosen it:
import geopandas as gpd
import shapely
from detect_invalid_geometries import scan_file
def test_bowtie_detected(tmp_path):
bowtie = shapely.from_wkt("POLYGON ((0 0, 1 1, 1 0, 0 1, 0 0))")
path = tmp_path / "bad.gpkg"
gpd.GeoDataFrame(geometry=[bowtie], crs="EPSG:27700").to_file(path)
violations = scan_file(path)
assert len(violations) == 1
assert "Self-intersection" in violations[0]["reason"]
Then trigger the scan from GitHub Actions on any change to a spatial file. The paths filter keeps the job off unrelated commits, and a non-zero exit fails the required check:
name: Detect Invalid Geometries
on:
pull_request:
paths:
- "data/**/*.gpkg"
- "data/**/*.geojson"
- "data/**/*.shp"
jobs:
geometry-validity:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install
run: pip install shapely==2.0.4 geopandas==1.0.1 pyogrio==0.9.0
- name: Scan changed geometry files
run: |
git diff --name-only --diff-filter=ACMRT origin/${{ github.base_ref }} HEAD \
| grep -E '\.(gpkg|geojson|shp)$' \
| xargs -r python scripts/detect_invalid_geometries.py
Promote the geometry-validity job to a required status check under branch protection so it becomes an enforced merge gate rather than an advisory log line, exactly as described for the policy enforcement gates for data PRs.
Long-term compliance best practices
- Pin
shapelyand assert its GEOS version. Validity semantics live in GEOS, not in Python. Addassert shapely.geos_version >= (3, 11, 0)at job startup so a runner with a different GEOS build fails loudly instead of producing results that disagree with a contributor’s laptop. - Report the coordinate, not just the reason.
explain_validityreturns the location of the defect in the layer’s own CRS. Surfacing it — as the script does — lets a reviewer jump straight to the offending vertex in QGIS instead of hunting through a large layer. - Never auto-repair inside the gate.
make_validchanges area and can split features. Keep this check read-only; route repairs to a separate, reviewed commit and re-validate the result, as covered in the parent topology guide. - Treat empty geometries as failures. GEOS calls an empty geometry valid. For a production layer that is almost always a defect, so test
shapely.is_emptyexplicitly rather than relying onis_validalone. - Iterate multi-part features.
explain_validityreports one reason for aMultiPolygon; loop overgeom.geomswhen you need a defect list for every part of a multi-part feature. - Re-validate after every transform. Reprojection, clipping, and simplification can each introduce invalidity into previously clean data, so run this scan again on any derivative before it is written, not only at initial ingest.
Related
- Automated Topology & Geometry Validation — the parent guide covering coverage topology, repair previews, and the full validity rule set
- Policy Enforcement Gates for Data PRs — turn this validity check into an enforced merge gate alongside license and metadata rules
- Spatial Data Schema Linting in CI — the structural checks that run next to geometry validation
- CI/CD Validation & Policy Enforcement for Spatial Data — the framework governing when a failing geometry check blocks a merge