Geometry Validation Patterns
A geometry validation pattern is a reusable, deterministic check that interrogates one structural property of a spatial feature — its validity, ring closure, orientation, precision, or minimum size — and returns a pass/fail verdict under an explicit tolerance model. These patterns matter because geometric corruption is silent: a polygon can be byte-identical to its source yet self-intersecting, a feature can satisfy its schema yet carry a sub-millimetre sliver that breaks every spatial index downstream, and a reprojected ring can collapse to fewer vertices than the OGC model permits. This page sits directly beneath Spatial Test Pattern Design & Implementation, the implementation layer of the core geospatial QA architecture, and catalogues the geometry-level patterns that form the first hard gate before topology, attribute, or service checks run. It is written for GIS QA engineers, data engineers, Python developers, and platform teams who need geometry checks that behave identically on a laptop and a constrained CI runner.
Each branch answers a different correctness question. Structural patterns ask is this geometry well-formed under the OGC Simple Features model — closed rings, enough vertices, no edges crossing. Precision patterns ask is this geometry stable under floating-point representation — whether snapping to a grid removes drift without destroying the feature, and whether residual fragments are real or numerical noise. Dimensionality patterns ask does this geometry carry the coordinate components its consumers expect — that an empty geometry is rejected before it poisons a predicate, and that a stray Z value does not silently change comparison semantics. A mature suite layers all three and runs the cheapest, most deterministic ones first, exactly as the GIS test pyramid prescribes.
Pattern Taxonomy and Tolerance Reference
The table below maps each geometry validation pattern to the tolerance strategy it requires, a typical threshold range, and the CRS units that threshold is expressed in. Threshold magnitude is meaningless without its unit: 1e-6 is sub-micrometre in a projected CRS measured in metres but roughly 0.1 m in EPSG:4326 degrees at the equator. Read this table together with the rule for setting up spatial tolerance thresholds in assertions, which derives the numbers below from the CRS unit and the operation that produced the geometry.
| Validation pattern | Predicate / metric | Tolerance strategy | Typical threshold | Applicable CRS units |
|---|---|---|---|---|
| Validity | is_valid, explain_validity |
exact, GEOS make-valid repair | boolean | any |
| Simplicity | is_simple |
exact | boolean | any |
| Ring closure | first vertex == last | exact equality | exact | any |
| Minimum vertices | len(coords) >= 4 (ring) |
exact count | 4 for a ring |
n/a |
| Ring orientation | signed area / orient |
sign test | exterior CCW, holes CW | projected |
| Self-intersection | is_valid + explain_validity |
exact, no epsilon | boolean | any |
| Sliver / micro-area | area floor |
absolute area minimum | 1e-6–1e-2 m² |
projected (m²) |
| Precision / grid snap | set_precision(grid_size) |
snap-to-grid quantum | 1e-7–1e-3 m |
CRS units |
| Coordinate drift | hausdorff_distance |
relative distance bound | 0.5%–2% of extent |
projected (m) |
| Area parity | area delta | relative epsilon | 1e-6–1e-3 |
projected (m²) |
| Empty / null | is_empty, is None |
exact, fail-fast | boolean | any |
Two numeric metrics underpin most precision patterns, and both are worth formalising rather than hard-coding as a magic number. The relative area delta enforces that a snapped or round-tripped geometry retains its area within a fraction
When you need to bound how far any vertex moved after a snap or reprojection — rather than how the total area changed — the directed Hausdorff distance captures the worst-case point-to-set displacement, which is the metric that actually correlates with visible geometric corruption:
Structural Validity and the Repair Boundary
The first pattern every geometry must pass is structural validity. Under the OGC Simple Features specification, a polygon’s rings must be closed, must not self-intersect, and must carry at least four coordinates (three distinct plus the closing repeat). GEOS, exposed through Shapely 2.x, evaluates these conditions and reports the precise defect, so a validity gate should always surface why a geometry failed rather than just that it did.
import shapely
from shapely.validation import explain_validity
if not shapely.is_valid(geom):
reason = explain_validity(geom) # e.g. "Self-intersection[12.3 4.5]"
repaired = shapely.make_valid(geom) # GEOS >= 3.10 structural repair
The decision that matters operationally is the repair boundary: which defects you auto-repair with make_valid and which you reject outright. Repairing a self-touching ring is usually safe and idempotent; “repairing” a geometry whose coordinates were mangled by a bad transform produces a plausible-but-wrong shape that passes every later check. The discipline is to repair only known-benign classes (self-touch, ring orientation) and hard-reject everything else, recording the original explain_validity string in the failure artifact. Relational checks that depend on validity — adjacency, containment — belong to Topology Rule Enforcement and must never run on an unrepaired input, because a self-intersecting ring makes downstream predicates mathematically undefined.
Ring Orientation and Closure
Even a “valid” polygon can violate consumer expectations if its rings wind the wrong way. The OGC and GeoJSON conventions disagree (GeoJSON mandates exterior rings counter-clockwise; some shapefile tooling emits clockwise), so an orientation pattern normalises winding before serialization rather than discovering the mismatch in a rendering engine.
from shapely import Polygon
from shapely.geometry.polygon import orient
# Force exterior ring CCW (sign +1) and interior holes CW
normalized = orient(polygon, sign=1.0)
# Closure is a structural invariant: first coordinate must equal last
ring = list(polygon.exterior.coords)
assert ring[0] == ring[-1], "exterior ring is not closed"
Closure is checked as exact coordinate equality, never with a tolerance — an unclosed ring is a structural defect, not a precision one, and tolerating it masks an ETL bug. Orientation, by contrast, is a normalisation step you apply on write so that every downstream consumer reads a consistent winding.
Sliver and Micro-Geometry Detection
Slivers are the most insidious geometry defect because they are technically valid. Overlay operations — union, intersection, difference — routinely spawn sub-millimetre fragments where two boundaries almost, but not quite, coincide. These pass is_valid, survive serialization, and then bloat spatial indexes and produce phantom adjacency in later joins. The pattern is an absolute area floor, applied in projected units where area is meaningful.
import shapely
AREA_FLOOR_M2 = 1e-3 # config-driven, per CRS
# Flag every polygonal part below the floor for review, not silent deletion
slivers = [g for g in shapely.get_parts(multipolygon)
if shapely.area(g) < AREA_FLOOR_M2]
Flag slivers rather than dropping them: an automated delete that runs before a human confirms the floor is correct will silently erase legitimate narrow features (rivers, road medians, easements). The floor itself must be expressed in the CRS’s area unit — applying a metre-based floor to EPSG:4326 degrees is a units error that either deletes everything or nothing.
Precision, Grid Snapping and Float Drift
Floating-point representation is the root cause of most “geometry changed but the data didn’t” reports. Two geometries that are mathematically identical can carry coordinates differing in the fifteenth decimal place after a round-trip, and naive equality flags them as different. The pattern is to snap every geometry onto an explicit precision grid before any comparison, collapsing drift deterministically.
import shapely
GRID = 1e-6 # snap quantum in CRS units
a = shapely.set_precision(geom_a, grid_size=GRID)
b = shapely.set_precision(geom_b, grid_size=GRID)
# Compare snapped geometries, and bound how far any vertex moved
assert shapely.equals_exact(a, b, tolerance=0.0)
assert shapely.hausdorff_distance(a, geom_a) <= GRID
set_precision is the Shapely 2.x interface to GEOS’s precision model; it both snaps coordinates and re-validates topology, which can legitimately drop a sliver that falls inside the grid quantum. That is why the Hausdorff guard above matters — it confirms the snap moved no vertex further than the grid size, so you can distinguish an intended quantisation from a transform that displaced the feature. The grid quantum is the single most CRS-sensitive parameter in geometry validation, which is exactly the concern the spatial tolerance thresholds work formalises. This same configuration-driven precision model feeds the attribute and metadata checks gate, where schema and tolerance matrices are evaluated together before ingestion, and it underpins cross-format parity testing, where each serialization driver applies its own precision limits.
Memory-Safe Execution for Large Datasets
A geometry validation pattern is only useful if it can run against production-scale data inside a CI runner’s memory ceiling. Loading a multi-gigabyte feature collection into a single DataFrame guarantees an out-of-memory kill on a constrained runner, so the execution pattern streams features in bounded windows and yields a verdict per feature. This decouples I/O from geometric evaluation and keeps the resident set flat regardless of dataset size — the same streaming discipline that Async Execution for Large Datasets scales across distributed workers.
# memory_safe_validator.py — Shapely 2.x, pyogrio 0.7+, PyYAML
import logging
from typing import Any, Dict, Iterator
import pyogrio
import shapely
import yaml
from shapely.validation import explain_validity
logger = logging.getLogger(__name__)
def load_config(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh)
def stream_validate(source: str, config: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
"""Yield one verdict per feature without loading the full dataset."""
gv = config["geometry_validation"]
chunk = gv.get("chunk_size", 5000)
grid = gv["tolerances"]["xy_tolerance"]
area_floor = gv["tolerances"].get("area_floor", 0.0)
total = pyogrio.read_info(source)["features"]
for offset in range(0, total, chunk):
gdf = pyogrio.read_dataframe(
source, skip_features=offset, max_features=chunk
)
for i, geom in enumerate(gdf.geometry):
idx = offset + i
if geom is None or geom.is_empty:
yield {"status": "reject", "index": idx, "reason": "null_or_empty"}
continue
snapped = shapely.set_precision(geom, grid_size=grid)
if not shapely.is_valid(snapped):
yield {"status": "flag", "index": idx,
"reason": explain_validity(snapped)}
elif shapely.area(snapped) < area_floor:
yield {"status": "flag", "index": idx, "reason": "sliver_below_floor"}
else:
yield {"status": "pass", "index": idx, "reason": None}
def run_validation_pipeline(source: str, config_path: str) -> Dict[str, int]:
config = load_config(config_path)
fail_fast = config["geometry_validation"].get("fail_fast", False)
tally = {"pass": 0, "flag": 0, "reject": 0}
for result in stream_validate(source, config):
tally[result["status"]] += 1
if result["status"] == "reject" and fail_fast:
raise RuntimeError(
f"validation failed at feature {result['index']}: {result['reason']}"
)
return tally
The tolerance matrix the validator reads is version-controlled configuration, not embedded constants, so the same code enforces a tight grid in staging and a relaxed one for noisy third-party feeds without a code change:
# validation_config.yaml
geometry_validation:
precision_model: "FLOATING"
chunk_size: 5000
fail_fast: true
tolerances:
xy_tolerance: 0.000001 # snap quantum, CRS units
area_floor: 0.001 # m^2 sliver floor, projected CRS
crs_handling:
enforce_epsg: true
target_epsg: 3857
For DataFrame-centric workflows the generator adapts to a vectorised pass over a GeoSeries, but only within a chunk so the memory ceiling still holds. When the input is polygonal, validating polygon topology with GeoPandas provides the NumPy-backed approach that complements this streaming architecture.
Production-Grade pytest Suite
In a CI suite these patterns become parameterised assertions with tolerances loaded from a fixture, so a run is reproducible across machines. The example uses pytest 7+, Shapely 2.x and GeoPandas 0.14+.
import json
import geopandas as gpd
import pytest
import shapely
@pytest.fixture(scope="session")
def tol() -> dict:
# Tolerances are config, not magic numbers — one source of truth.
with open("tests/fixtures/tolerances.json", encoding="utf-8") as fh:
return json.load(fh)["epsg_3857"]
@pytest.fixture
def parcels() -> gpd.GeoDataFrame:
gdf = gpd.read_file("tests/fixtures/parcels.gpkg")
assert gdf.crs.to_epsg() == 3857 # metadata gate first
return gdf
def test_no_invalid_geometries(parcels):
invalid = parcels[~parcels.geometry.map(shapely.is_valid)]
assert invalid.empty, f"{len(invalid)} invalid geometries"
def test_no_empty_or_null(parcels):
empties = parcels.geometry.is_empty | parcels.geometry.isna()
assert not empties.any(), f"{int(empties.sum())} empty/null geometries"
def test_no_slivers(parcels, tol):
areas = parcels.geometry.area # vectorised, GEOS-backed
assert (areas >= tol["area_floor_m2"]).all()
def test_precision_snap_is_stable(parcels, tol):
grid = tol["xy_tolerance"]
for geom in parcels.geometry:
snapped = shapely.set_precision(geom, grid_size=grid)
# snapping must not displace any vertex beyond the grid quantum
assert shapely.hausdorff_distance(snapped, geom) <= grid
Because the tolerances are injected, the same assertions tighten for a survey-grade dataset and relax for a generalised basemap without touching test logic — and they read as a contract any reviewer can audit.
PostGIS Database-Side Counterparts
Many geometry patterns are cheaper to enforce inside the database at the ingestion boundary, and most Shapely predicates have a direct PostGIS analogue. Running both surfaces engine disagreement: a geometry GEOS calls valid and PostGIS calls invalid is a defect you want to discover before production, not after.
-- Validity + sliver gate at ingestion, one pass
SELECT parcel_id, ST_IsValidReason(geom) AS reason
FROM parcels
WHERE NOT ST_IsValid(geom)
OR ST_Area(geom) < 0.001; -- m^2 floor in a projected SRID
-- Snap to a precision grid server-side (mirrors set_precision)
UPDATE parcels
SET geom = ST_ReducePrecision(geom, 1e-6)
WHERE NOT ST_Equals(geom, ST_ReducePrecision(geom, 1e-6));
Driving these from the test runner with psycopg2 keeps database assertions in the same suite as the Python ones, so a single CI job covers both surfaces:
import psycopg2
with psycopg2.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("SELECT count(*) FROM parcels WHERE NOT ST_IsValid(geom);")
(invalid_count,) = cur.fetchone()
assert invalid_count == 0, f"{invalid_count} invalid geometries in DB"
ST_ReducePrecision (PostGIS 3.1+) is the server-side equivalent of set_precision; pin the PostGIS GEOS build to match your Shapely wheel so the two snap identically.
Pipeline Integration and Observability
Geometry validation earns its keep only when it runs automatically, deterministically, and observably. Wire the patterns into CI as ordered gates so the cheapest checks short-circuit first:
- Pre-merge: structural validity, empty/null, and ring closure on the PR’s changed features — fast, fail-fast, blocks the merge.
- Nightly / full sync: precision-snap stability, sliver floors, and area-parity against production-scale data on distributed runners.
- Artifact generation: emit machine-readable verdicts (JSON or Parquet) plus a spatial error layer such as
invalid_features.geojsonfor QA triage.
Pin libgeos and PROJ in the container image so the geometry engine is bitwise reproducible across runners; an unpinned PROJ grid shift moves coordinates by metres between builds and manifests as a flaky precision failure. Serialize every verdict — the failing predicate, the observed value, the threshold, and the CRS — so platform teams can track geometry-degradation rates over time rather than re-reading console output. The malformed fixtures these gates must survive (empty geometries, null-CRS tags, mangled WKB) come from the mocking geospatial data for tests approach.
Common Failure Modes and Gotchas
- Repairing instead of rejecting.
make_validwill happily “fix” a geometry whose coordinates were corrupted by a bad transform, producing a plausible-but-wrong shape that passes every later check. Auto-repair only known-benign classes; hard-reject the rest. - CRS unit mismatch on the grid quantum. A
1e-6snap is sub-micrometre in EPSG:3857 metres but ~0.1 m in EPSG:4326 degrees. Reproject to a projected CRS before applying absolute precision or area thresholds. - Sliver floor applied in degrees. An area floor expressed in m² but evaluated against a geographic CRS either deletes nothing or everything. Compute area in a projected CRS.
- Closure checked with a tolerance. An unclosed ring is a structural defect, not float drift — assert exact first-equals-last, never
equals_exactwith an epsilon, or you will mask an ETL bug. - Snap-to-grid dropping real features.
set_precisioncan legitimately collapse a narrow feature inside the grid quantum; guard with a Hausdorff bound so an intended quantisation is distinguishable from a displacement. - Empty geometry poisoning predicates. An empty or null geometry makes downstream area, validity, and relational checks undefined; reject it as the very first gate, before any maths runs.
- Mixed Z/M coordinates changing comparison semantics. A stray Z value silently alters
equals_exactand area results; assert the expected dimensionality (has_z) explicitly when the pipeline is 2D. - Unpinned GEOS between Shapely and PostGIS. Divergent engine builds disagree at the boundary and produce “passes locally, fails in CI” reports — pin both and validate cross-engine.
Conclusion
Geometry validation patterns give an engineer a decision procedure: identify which structural, precision, or dimensionality property a rule actually constrains, pick the matching pattern, then apply that pattern’s tolerance strategy in the correct CRS unit. Layered this way — validity first, precision and slivers next, with tolerances loaded from config, engines pinned, and verdicts logged — geometry checks become deterministic CI gates rather than flaky scripts, and spatial corruption is rejected at the earliest boundary instead of discovered in production maps. For how these checks compose with topology, attribute, and parity patterns into a full validation architecture, return to Spatial Test Pattern Design & Implementation.