Geometry Validity Checks for Vector Data
Invalid geometries are a silent liability in any spatial data pipeline. Self-intersecting polygons, unclosed rings, and duplicate vertices do not always raise loud errors — they propagate through spatial joins, break rendering engines, and surface as compliance failures weeks later when a regulatory audit discovers corrupted parcel boundaries or misaligned utility networks. Systematic geometry validity checking is therefore a foundational control, not an optional cleanup step.
This page covers the full workflow: from understanding what “valid” means under the Open Geospatial Consortium (OGC) Simple Features specification, through production-safe Python and SQL implementations, to automating validation inside a continuous integration pipeline. GIS analysts, QA engineers, and platform teams will each find directly applicable patterns here. For the broader quality control context that governs this work, see Core Spatial QC Fundamentals & Standards.
Prerequisites
Before running any validity diagnostics, confirm the following are in place. Skipping these steps produces false positives and masks genuine defects.
- Python 3.10+ with GeoPandas 0.14+, Shapely 2.0+, and pyogrio 0.7+. The
make_validfunction in Shapely 2.x uses GEOS GEOSMakeValid internally and handles far more failure modes than earlier versions. - PostgreSQL 14+ with PostGIS 3.2+. Earlier PostGIS versions lack
ST_IsValidDetail, which returns structured diagnostics rather than a plain boolean. - GDAL/OGR 3.4+ for format-agnostic ingestion. Older GDAL releases may silently truncate coordinate precision during read, altering validity state.
- Coordinate Reference System (CRS) alignment. All input layers must share a consistent CRS before validation begins. Mixed projections introduce coordinate drift that triggers spurious validity errors. Apply coordinate reference system normalization to every input layer before any geometric predicate runs.
- Explicit geometry column typing. Geometry columns must carry a declared type (
GEOMETRY,POLYGON,MULTIPOLYGON, etc.). Untyped or mixed-type columns require attribute schema mapping before validation can produce reliable results. - Data volume estimate. Datasets exceeding 500k features should be processed in chunks or via a spatial database. Enable a spatial index (R-tree via GeoPackage/PostGIS GIST) before running bulk diagnostics — full-table sequential scans under validity checks are a common performance anti-pattern.
Conceptual Foundation
The OGC Simple Features Specification (ISO 19125) defines precise rules for what makes a geometry valid. Understanding these rules is the prerequisite for selecting the right repair strategy — treating every invalid geometry with a blanket buffer(0) call is a common mistake that silently alters topology.
Key OGC validity constraints:
- Polygons: The boundary must form a closed ring. Interior holes must lie entirely within the exterior ring and must not touch each other except at single points. The exterior ring must be wound counter-clockwise; holes wind clockwise (right-hand rule).
- Self-intersections: No two edges of a ring may cross. A figure-eight or bowtie polygon violates this rule even if it appears visually plausible.
- Duplicate or collinear points: Consecutive duplicate vertices (same coordinate repeated) and three or more collinear points on a ring are degenerate but not always invalid under OGC rules — however, they can cause downstream predicate failures in specific implementations.
- Multipart geometries: Each component of a
MULTIPOLYGONmust itself be a valid polygon, and no two components may overlap (they may share boundary edges at single points only). - LineStrings: A valid
LINESTRINGmust have at least two distinct points. ALINEARRINGmust be closed (first point equals last point) and have at least four points.
These constraints are enforced by GEOS (the underlying geometry engine used by PostGIS, Shapely, and GDAL). When a geometry violates any of them, ST_IsValid / is_valid returns false, and the diagnostic functions return a human-readable location string such as Self-intersection[412350.2 6189012.7].
The OGC topology rules page covers the formal predicate logic in depth, including how spatial relationships between features (not just within a single geometry) define topological correctness.
Step-by-Step Implementation
Step 1: Ingest and Normalize Input Data
Raw vector data rarely arrives validation-ready. Load with explicit type handling, remove nulls immediately, normalize CRS, and strip exact-duplicate geometries before any diagnostic pass.
import geopandas as gpd
from shapely.validation import make_valid
# Production-safe ingestion
gdf = gpd.read_file("input_data.gpkg", engine="pyogrio")
# Quarantine null geometries — they skew metrics and break spatial predicates
gdf = gdf[gdf.geometry.notna()].copy()
# Normalize to a common CRS before any validity check
gdf = gdf.to_crs(epsg=4326)
# Remove exact-duplicate geometries (same WKB representation)
gdf = gdf.drop_duplicates(subset=["geometry"])
print(f"Loaded {len(gdf)} features for validation.")
Verification: assert gdf.geometry.notna().all() — no rows should pass with null geometry.
Step 2: Execute Boolean Validity Diagnostics
Boolean checks reveal whether a geometry is broken. Diagnostic functions reveal why. Always capture both: validity status drives routing logic; the reason string drives repair selection.
from shapely.validation import explain_validity
# Boolean validity flag
gdf["is_valid"] = gdf.geometry.is_valid
# Human-readable failure description (e.g. "Self-intersection[412350 6189012]")
gdf["validity_reason"] = gdf.geometry.apply(
lambda geom: "Valid" if geom.is_valid else explain_validity(geom)
)
# Log failure rates for compliance reporting
total = len(gdf)
invalid_count = (~gdf["is_valid"]).sum()
rate = invalid_count / total * 100
print(f"Validation complete: {invalid_count}/{total} invalid features ({rate:.2f}%).")
PostGIS equivalent using ST_IsValidDetail (PostGIS 3.2+), which returns a composite row with valid, reason, and location fields:
SELECT
feature_id,
(ST_IsValidDetail(geom)).valid AS is_valid,
(ST_IsValidDetail(geom)).reason AS failure_reason,
ST_AsText((ST_IsValidDetail(geom)).location) AS failure_location
FROM spatial_features
WHERE NOT ST_IsValid(geom);
Verification: The is_valid column should be 100% true for a clean dataset. Any false rows proceed to Step 3.
Step 3: Classify and Isolate Invalid Records
Separate valid from invalid features into distinct outputs. Tag each invalid record with a standardized error category derived from the reason string. Isolation enables targeted repair without risking modification of valid geometry.
import re
# Error category extraction from explain_validity strings
def categorize_error(reason: str) -> str:
reason_lower = reason.lower()
if "self-intersection" in reason_lower:
return "SELF_INTERSECTION"
if "ring" in reason_lower and "closed" in reason_lower:
return "UNCLOSED_RING"
if "duplicate" in reason_lower:
return "DUPLICATE_POINT"
if "too few" in reason_lower:
return "DEGENERATE_GEOMETRY"
if "hole" in reason_lower:
return "INVALID_HOLE"
return "OTHER"
gdf_valid = gdf[gdf["is_valid"]].copy()
gdf_invalid = gdf[~gdf["is_valid"]].copy()
gdf_invalid["error_category"] = gdf_invalid["validity_reason"].apply(categorize_error)
# Export for audit trail — maintain original feature IDs
gdf_invalid.to_parquet("invalid_features_audit.parquet")
gdf_valid.to_parquet("valid_features.parquet")
print(gdf_invalid["error_category"].value_counts().to_string())
Verification: Confirm len(gdf_valid) + len(gdf_invalid) == len(gdf) — no features should be silently dropped.
Step 4: Diagnose Root Causes Against Topological Standards
Classification alone is insufficient for scalable remediation. Each error category maps to a specific OGC violation, and each violation requires a different repair algorithm. Applying the wrong repair degrades valid geometry or introduces new failures.
| Error Category | OGC Violation | Typical Cause | Repair Strategy |
|---|---|---|---|
SELF_INTERSECTION |
Boundary edges cross | Digitization error, overlay artifact | make_valid (splits into valid parts) |
UNCLOSED_RING |
First ≠ last coordinate | Truncated export, format conversion | Close ring programmatically, then validate |
DUPLICATE_POINT |
Consecutive identical coords | GPS noise, rounding during projection | Remove duplicates with simplify(0) |
DEGENERATE_GEOMETRY |
Ring has < 4 points | Sub-pixel polygon from rasterization | Delete or merge with adjacent features |
INVALID_HOLE |
Hole exterior to shell | Clip operation error | make_valid or manual reconstruction |
For the formal predicate definitions underlying these constraints, see understanding OGC topology rules.
Step 5: Apply Targeted Repairs and Re-validate
Repair logic must be conditional on error category. A blanket buffer(0) call can collapse near-zero-area rings that are topologically valid, and it fails entirely on certain multipart configurations.
def targeted_repair(row):
geom = row["geometry"]
category = row.get("error_category", "OTHER")
if category == "DUPLICATE_POINT":
# Remove consecutive duplicate vertices
repaired = geom.simplify(0, preserve_topology=True)
elif category == "UNCLOSED_RING":
# Force ring closure via make_valid (GEOS handles this)
repaired = make_valid(geom)
elif category in ("SELF_INTERSECTION", "INVALID_HOLE"):
# make_valid splits bowtie polygons into a MultiPolygon
repaired = make_valid(geom)
elif category == "DEGENERATE_GEOMETRY":
# Flag for deletion — cannot repair a ring with < 4 points
return None
else:
repaired = make_valid(geom)
# Confirm repair succeeded
return repaired if repaired.is_valid else None
gdf_invalid["geometry"] = gdf_invalid.apply(targeted_repair, axis=1)
# Separate records that could not be repaired automatically
gdf_repaired = gdf_invalid[gdf_invalid.geometry.notna() & gdf_invalid.geometry.is_valid].copy()
gdf_escalated = gdf_invalid[gdf_invalid.geometry.isna() | ~gdf_invalid.geometry.is_valid].copy()
print(f"Repaired: {len(gdf_repaired)} | Escalated for manual review: {len(gdf_escalated)}")
For interactive polygon correction in QGIS 3.28+, the validating polygon self-intersections in QGIS guide covers the Topology Checker plugin workflow and batch-fix operations.
Re-validation pass:
# Merge repaired records back with the valid set
gdf_final = gpd.GeoDataFrame(
pd.concat([gdf_valid, gdf_repaired], ignore_index=True),
crs=gdf.crs
)
# Re-run diagnostics to confirm no repairs introduced new failures
assert gdf_final.geometry.is_valid.all(), "Post-repair validation failed — inspect gdf_escalated"
Verification: gdf_final.geometry.is_valid.all() must return True before proceeding.
Step 6: Automate and Monitor at Scale
Manual validation does not scale beyond development workflows. Embed geometry checks into ingestion workers, database triggers, or CI/CD gates. Implement threshold-based alerting: an invalid-feature rate above 2% should halt downstream processing and notify the data stewardship team.
PostGIS enforcement trigger:
-- Raise an exception immediately on any invalid geometry write
CREATE OR REPLACE FUNCTION enforce_geometry_validity()
RETURNS TRIGGER AS $$
BEGIN
IF NOT ST_IsValid(NEW.geom) THEN
RAISE EXCEPTION 'Invalid geometry (id=%): %',
NEW.feature_id,
ST_IsValidReason(NEW.geom);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_geometry_validity
BEFORE INSERT OR UPDATE ON spatial_features
FOR EACH ROW EXECUTE FUNCTION enforce_geometry_validity();
Python pipeline gate (CI/CD):
INVALID_RATE_THRESHOLD = 0.02 # 2% halt threshold
invalid_rate = (~gdf["is_valid"]).mean()
if invalid_rate > INVALID_RATE_THRESHOLD:
raise RuntimeError(
f"Geometry validity gate failed: {invalid_rate:.1%} invalid "
f"(threshold {INVALID_RATE_THRESHOLD:.1%}). Halting pipeline."
)
Store per-run validity metrics (feature count, error rate by category, repair success rate) in a monitoring table or observability platform. Trends over time surface recurring digitization errors and projection drift introduced during extract-transform-load operations.
Common Failure Modes & Fixes
| Shapely / PostGIS Error String | Root Cause | Fix |
|---|---|---|
Self-intersection[x y] |
Ring edges cross at coordinates x, y | make_valid() / ST_MakeValid() |
Ring Self-intersection[x y] |
Exterior ring crosses itself (bowtie) | make_valid() — splits into MULTIPOLYGON |
Hole lies outside shell |
Digitization placed a hole outside its parent ring | make_valid() or delete the hole feature |
Interior is disconnected |
Complex polygon with topology that creates isolated interior regions | make_valid() — may split into multiple parts |
Too few points in geometry component[3] |
Ring has only 3 coordinates (degenerate triangle with no interior) | Delete or merge; cannot be repaired to a meaningful polygon |
Duplicate Rings |
Two rings in a multipart geometry are identical | Deduplicate rings before writing |
ST_IsValid returned false (no reason) |
GEOS version mismatch or corrupt WKB | Re-serialize via ST_AsText → ST_GeomFromText to reset binary representation |
Performance & Scale Considerations
Indexing before diagnostics. Always ensure a spatial index (PostGIS GIST, GeoPackage RTree) exists before running bulk validity scans. ST_IsValid itself does not use the spatial index, but any subsequent spatial predicate (joins, intersections) run during repair does.
Chunk processing for large datasets. For datasets exceeding 500k features in Python, process in chunks to avoid memory exhaustion:
CHUNK_SIZE = 50_000
chunks = [gdf.iloc[i:i + CHUNK_SIZE] for i in range(0, len(gdf), CHUNK_SIZE)]
results = []
for chunk in chunks:
chunk = chunk.copy()
chunk["is_valid"] = chunk.geometry.is_valid
results.append(chunk)
gdf_validated = gpd.GeoDataFrame(pd.concat(results, ignore_index=True), crs=gdf.crs)
For multi-million-feature workloads, move to a distributed execution strategy. The batch processing large spatial datasets page covers partitioning strategies with Dask and Apache Sedona.
Cross-platform tolerance differences. Floating-point precision variations between GEOS versions (e.g., PostGIS 3.2 vs. Shapely 2.0) can cause the same geometry to be valid in one engine and invalid in another. Validate critical datasets in both environments when data will be consumed across platforms.
Parallelism. Use concurrent.futures.ProcessPoolExecutor for CPU-bound validity checks in Python — is_valid releases the GIL for the underlying GEOS call, so process-level parallelism scales near-linearly up to core count.
Integration with the Validation Pipeline
Geometry validity checking sits at the ingestion stage of the broader validation directed acyclic graph (DAG): it must pass before any rule-engine predicates run, because invalid geometries cause undefined behavior in spatial predicates such as ST_Intersects, ST_Within, and ST_DWithin.
The rule engine built with GeoPandas expects all input geometries to be pre-validated; it does not perform internal validity checks before applying business rules. Treat geometry validity as a non-negotiable gate that precedes every other QC step.
For pipeline-level error routing and severity assignment after the validity gate passes, the categorizing and prioritizing spatial errors page defines the severity model (blocker / warning / informational) and the dead-letter queue patterns used when automated repair fails.
The validation pipeline architecture pillar covers how this gate fits into the full DAG, from ingestion through rule evaluation to compliance output.
Frequently Asked Questions
What is the difference between ST_IsValid and ST_MakeValid in PostGIS?
ST_IsValid is a read-only predicate that returns true or false (and optionally a reason string via ST_IsValidReason). ST_MakeValid is a repair function that attempts to produce a valid geometry by inserting additional nodes or splitting self-intersecting rings — it modifies geometry and may change topology. Never call ST_MakeValid without first confirming the geometry is actually invalid; running it on valid geometry is safe but adds unnecessary overhead.
Should I use Shapely's buffer(0) or make_valid for geometry repair?
Prefer make_valid (Shapely 2.0+, wrapping GEOS GEOSMakeValid) over buffer(0). The buffer trick can collapse near-degenerate rings and discard small but legitimate geometry. make_valid preserves more of the original intent and handles a wider range of failure modes — including cases where buffer(0) returns an empty geometry.
How do I prevent invalid geometries from entering PostGIS in the first place?
Add a BEFORE INSERT OR UPDATE trigger that calls ST_IsValid on each incoming geometry and raises an exception on failure. Pair this with a partial index on WHERE NOT ST_IsValid(geom) to expose any records that bypass the trigger during bulk loads (COPY statements bypass row-level triggers — use a separate post-load audit query instead).
What validity threshold is acceptable for production spatial data?
There is no universal number. Regulatory frameworks such as the INSPIRE Directive require 100% geometry validity before dataset publication. For internal pipelines, teams commonly treat an invalid-feature rate above 0.5–2% as a halt condition. The appropriate threshold depends on how downstream systems handle invalid geometry — some fail loudly, others silently produce wrong spatial join results.
Related:
- Understanding OGC Topology Rules — the formal predicate definitions that define what “valid” means
- Coordinate Reference System Precision Standards — CRS normalization before validity checks
- Attribute Schema Mapping for Spatial Datasets — schema alignment that precedes geometry validation
- Categorizing and Prioritizing Spatial Errors — severity routing after the validity gate
- Batch Processing Large Spatial Datasets — distributed strategies for >500k-feature validation runs