Geometry Validity & Topology Checks for Spatial Pipelines
Invalid geometries do not crash a spatial pipeline — they corrupt it quietly. A self-intersecting polygon passes ingestion, survives a spatial join with the wrong answer, and only surfaces weeks later as a misrendered service area or a regulatory map that fails audit. A ring stored clockwise instead of counter-clockwise inverts an “inside” test, so a parcel that should be included is silently dropped. Geometry validity and topology checking closes that gap by treating structural correctness as a measured, alertable signal at every pipeline hop, not as something a downstream renderer eventually complains about. This guide is written for the data engineers, GIS platform administrators, and SREs who own vector ingestion integrity, and it sits under the broader Spatial Data Freshness & Quality Metrics program, where it provides the structural-integrity gate that freshness, coverage, and sync checks rely on.
Architecture
Geometry validation and topology verification operate as a stateless, horizontally scalable gate between raw ingestion zones and curated data products. The layer isolates structural checking into a compute-optimized stage that leverages distributed spatial indexing — typically R-tree or Quadtree structures — to eliminate full-table scans during predicate evaluation. Raw vector payloads land in a transient staging bucket where coordinate reference system normalization runs first, because every topology predicate downstream assumes a consistent spatial datum. Running Coordinate Reference System Validation ahead of the validity gate is what prevents silent projection drift from being misread as a topology fault later in the run.
Once datum alignment is confirmed, a validation microservice or distributed Spark/Flink cluster applies OGC Simple Features compliance checks, ring orientation normalization, and self-intersection detection. This design explicitly decouples heavy geometric operations from transactional analytical workloads, so a GEOS-heavy ST_MakeValid pass on one partition never contends with query traffic on the curated store. Validation telemetry is routed to a centralized observability plane using the attribute conventions defined in the Geospatial Metric Taxonomy for ETL, so that an invalid-geometry count emitted from a PySpark UDF and one emitted from a PostGIS trigger land in the same namespace.
The gate sits in the middle of the quality chain. It consumes partitions that have already cleared Automated Row Count & Attribute Sync — so a topology spike can be attributed to corruption rather than to records that were never delivered — and it feeds Spatial Coverage & Extent Monitoring, which interprets whether repaired geometries still cover the area they should. When a partition fails repair, the gate short-circuits the run into quarantine using the degradation tiers from Fallback Chains for Spatial API Failures, rather than letting malformed polygons reach analytical consumers.
Metric Specification
Geometry validity monitoring rests on a tightly defined set of quantitative signals that measure spatial integrity at both the record and dataset levels. The primary signal is the invalid geometry ratio, the fraction of features in an ingestion window that fail an ST_IsValid predicate, computed per partition so that localized corruption is never diluted across a whole table. Topology violation density measures rule-specific failures — polygon overlaps, sliver gaps, dangling nodes, and non-planar intersections — normalized per 10,000 features so partitions of different sizes stay comparable. A third signal tracks distortion-induced degradation introduced when features are reprojected, sampled as the maximum coordinate delta before and after transformation.
These combine into a composite topology health score gis.spatial.topology.health_score, bounded to [0, 1] where 1.0 is structurally perfect:
where is the invalid geometry ratio, is topology violation density against a saturation ceiling (default 100 per 10k), is the count of features that required ST_MakeValid, is the partition feature count, and the weights sum to 1 (defaults 0.5 / 0.3 / 0.2, weighting outright invalidity most heavily).
| Metric | Instrument | Unit | Key dimensions |
|---|---|---|---|
gis.spatial.geometry.invalid_ratio |
gauge | ratio | partition_id, geom_type, srid |
gis.spatial.topology.violation_density |
gauge | per 10k features | partition_id, violation_type |
gis.spatial.geometry.repair_count |
counter | features | partition_id, repair_op |
gis.spatial.geometry.sliver_count |
counter | features | partition_id, srid |
gis.spatial.topology.health_score |
gauge | score [0,1] |
partition_id, dataset |
gis.spatial.reproject.coord_delta_max |
histogram | metres | src_srid, dst_srid |
All signals are collected at the partition level with rolling 7-day and 30-day averages and standard deviations, so thresholds adapt to seasonal spatial volume rather than firing on a fixed constant. The same series feeds Tracking Spatial Data Freshness SLAs, where the acceptable structural variance becomes part of the contract an SLA is measured against, and aligns to ingestion windows defined by Temporal Baseline Alignment for Time-Series GIS so a topology trend and a freshness trend agree on what “the same batch” means.
Pipeline Integration & Configuration
Deploying validity checks means embedding the predicates directly into the transformation DAG and emitting structured telemetry to the observability stack. The PySpark stage below validates geometry, classifies the violation type, and rolls partition-level metrics into a Delta table for export. In a production Sedona or GeoMesa deployment the is_valid and area columns are produced by native spatial UDFs (ST_IsValid, ST_Area); the column logic is what matters here.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, when, count, lit
spark = SparkSession.builder.appName("geometry-validation").getOrCreate()
raw_spatial_df = spark.table("staging.raw_features")
validation_df = (raw_spatial_df
# is_valid would be set by a real spatial UDF such as ST_IsValid(geom)
.withColumn("is_valid", col("geom").isNotNull())
.withColumn("violation_type",
when(~col("is_valid"), lit("INVALID_GEOMETRY"))
.when(col("geom_area") < 1e-6, lit("SLIVER_GEOMETRY")) # micro-polygons from FP noise
.otherwise(lit("NONE")))
.withColumn("crs_code", col("metadata.crs"))
)
# Emit partition-level metrics (gis.spatial.* namespace) for OTel / Prometheus export
metrics_df = validation_df.groupBy("partition_key", "crs_code").agg(
count("*").alias("total_records"),
count(when(~col("is_valid"), True)).alias("invalid_records"),
count(when(col("violation_type") == "SLIVER_GEOMETRY", True)).alias("sliver_count"),
)
metrics_df.write.format("delta").mode("append").save("/mnt/metrics/geometry_validation")
For pipelines that run inside PostGIS, the equivalent gate is expressed in SQL, where ST_IsValidReason makes the why of a failure a first-class column rather than a follow-up query:
-- Per-partition validity snapshot with reason classification
SELECT
count(*) AS total_records,
count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS invalid_records,
count(*) FILTER (WHERE ST_Area(geom) < 1e-6) AS sliver_count,
ST_SRID(ST_Collect(geom)) AS partition_srid,
sum(ST_NPoints(geom)) AS total_vertices
FROM staging.parcels
WHERE ingest_window = date_trunc('hour', now() AT TIME ZONE 'UTC');
Wrap the gate in a pre-execution hook so structural validity is confirmed before any spatial join or aggregation runs. Valid payloads flow to the curated store; invalid records are routed to a dead-letter queue at s3://dlq/spatial-invalid/{date}/ with their validation telemetry attached for manual review or automated reprocessing. Operators integrating natively can consult the PostGIS ST_IsValid reference for the GEOS predicates behind each check, and pair this gate with Calculating Spatial Coverage Gaps in Raster Datasets to keep end-to-end consistency across vector and raster modalities.
Threshold Design & Alerting Logic
Thresholds are tiered so that ordinary noise never pages a human while a genuine structural break halts propagation immediately. Spatial corruption is non-linear — a 1% invalid ratio concentrated in one administrative boundary is far more serious than 1% scattered uniformly — so the dynamic baseline tier compares each partition against its own rolling average rather than a global constant.
| Severity | gis.spatial.geometry.invalid_ratio |
gis.spatial.topology.violation_density |
Action |
|---|---|---|---|
| P3 (Info) | < 0.1% |
< 5 per 10k |
Log to dashboard, no page |
| P2 (Warning) | 0.1% – 2.0% |
5 – 50 per 10k |
Route to data-engineering queue, trigger automated quarantine |
| P1 (Critical) | > 2.0% |
> 50 per 10k |
Halt downstream materialization, page SRE/GIS Ops, trigger rollback |
A P2 fires when localized corruption can be quarantined without disrupting the broader run; a P1 indicates corruption broad enough that publishing would poison analytical stores. These translate into PromQL rules evaluated against the metrics backend:
groups:
- name: spatial-geometry.rules
rules:
- alert: InvalidGeometryRatioCritical
expr: gis_spatial_geometry_invalid_ratio > 0.02
for: 5m
labels: { severity: critical }
annotations:
summary: "Invalid geometry {{ $value | humanizePercentage }} on {{ $labels.partition_id }}"
- alert: TopologyViolationDensityWarning
expr: gis_spatial_topology_violation_density > 5
for: 10m
labels: { severity: warning }
- alert: TopologyHealthDegraded
# dynamic baseline: score must hold within 2σ of the partition's 30d mean
expr: |
gis_spatial_topology_health_score
< (avg_over_time(gis_spatial_topology_health_score[30d])
- 2 * stddev_over_time(gis_spatial_topology_health_score[30d]))
labels: { severity: warning }
annotations:
summary: "Topology health below dynamic baseline on {{ $labels.partition_id }}"
Route the severities through the Alertmanager configuration so that P2 and P1 reach the spatial validation team without the P3 stream drowning the channel:
route:
receiver: 'default-pager'
group_by: ['pipeline_id', 'severity', 'partition_key']
routes:
- match_re:
severity: 'critical|warning'
receiver: 'spatial-validation-team'
group_wait: 5m
repeat_interval: 30m
receivers:
- name: 'spatial-validation-team'
webhook_configs:
- url: 'https://hooks.internal/spatial-validation'
send_resolved: true
Failure Modes & Edge Cases
-
CRS mismatch masquerading as topology corruption. Violation density spikes immediately after ingestion even though the geometries look correct on a map. The cause is a wrong or missing SRID that distorts adjacency, not malformed rings. Diagnose with
SELECT ST_SRID(geom) FROM staging LIMIT 10;against the declared EPSG code, and confirm the projection — not the data — changed before touching any repair function. -
Ring orientation inverting inclusion tests. Exterior rings stored clockwise (OGC requires counter-clockwise; interior rings the reverse) pass some validity checks but invert “inside” semantics, so downstream renderers and point-in-polygon joins silently drop features. Normalize with
ST_MakeValid(ST_ForcePolygonCCW(geom))and verify the invalid ratio falls below 0.1%. -
Floating-point slivers below the alert floor. Coordinates carried at more than 15 decimal places introduce micro-gaps and zero-width spikes that GEOS reports as topology errors. Scored across many partitions the sliver count can dilute below the warning threshold, so dimension the counter by
partition_idand snap withST_SnapToGrid(geom, 1e-5)combined withST_Buffer(geom, 0). -
Repair masking real data loss. A blanket
ST_MakeValidcan collapse a self-intersecting polygon into a smaller valid one, dropping area without dropping a row, so cardinality checks pass cleanly. Trackgis.spatial.geometry.repair_countand cross-reference Spatial Coverage & Extent Monitoring to confirm repaired geometries still cover their expected extent. -
Quarantine bypass under backpressure. When the validation DAG checkpoints incompletely, P1/P2 alerts fire but invalid records still reach the curated store. The fix is an explicit dead-letter queue with
continue: falserouting, so valid and invalid payloads diverge deterministically rather than racing.
Troubleshooting Checklist
- Confirm the datum first. Run
ST_SRID(geom)against the expected EPSG code before any repair; a projection fault diagnosed as a topology fault wastes the entire remediation cycle. - Classify the failure with a reason. Use
ST_IsValidReason(geom)to split the invalid set into self-intersections, ring-orientation faults, and slivers — each takes a different fix. - Repair conservatively. Apply
ST_MakeValid(ST_ForcePolygonCCW(geom))for orientation and self-intersection faults; re-run validation and verify the invalid ratio drops below 0.1%. - Collapse micro-geometry. For sliver violations, snap to a grid tolerance with
ST_SnapToGrid(geom, 1e-5)andST_Buffer(geom, 0), then re-validate before promotion. - Verify no extent was lost. Compare partition
ST_Extentbefore and after repair so that a successful validity pass has not silently shrunk coverage. - Escalate recurring corruption. If the same violation pattern repeats across ingestion cycles, route the partition to the dead-letter queue, pin the last known-good batch, and feed the trend into capacity planning before it becomes an SLA breach.
By keeping validation compute separate from analytical workloads and treating every structural fault as a measured signal, geospatial platforms make geometry correctness observable — data engineers, GIS administrators, and SREs get a single auditable view of which polygons entered the pipeline malformed and what the gate did about it.
Related
- Spatial Data Freshness & Quality Metrics — the parent guide this validity gate reports into.
- Coordinate Reference System Validation — the datum gate that must run before topology checks to avoid false faults.
- Automated Row Count & Attribute Sync — the cardinality gate that runs immediately before this one.
- Spatial Coverage & Extent Monitoring — confirms repaired geometries still cover their expected area.
- Calculating Spatial Coverage Gaps in Raster Datasets — the deep dive on pairing topology checks with raster coverage.