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.


Geometry Validity Checking Pipeline Six-stage pipeline showing the flow from raw vector data ingestion through boolean diagnostics, error classification, targeted repair, re-validation, and automated enforcement. Ingest & Normalize Boolean Diagnostics Classify & Isolate Targeted Repair Re-validate & Audit Log Automate & Alert still invalid → manual review queue Step 1 Step 2 Step 3 Step 4–5 Step 5 Step 6

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_valid function 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 MULTIPOLYGON must itself be a valid polygon, and no two components may overlap (they may share boundary edges at single points only).
  • LineStrings: A valid LINESTRING must have at least two distinct points. A LINEARRING must 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_AsTextST_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:

Back to Core Spatial QC Fundamentals & Standards