Understanding OGC Topology Rules for Spatial Data Validation
Spatial data integrity relies on more than accurate coordinates and complete attribute tables. When features share boundaries, intersect, or form contiguous networks, their geometric relationships must adhere to strict spatial logic. Enforcing those relationships is the responsibility of topology validation — and the Open Geospatial Consortium (OGC) has codified the underlying model in the Simple Features Access specification. This page explains that model, translates it into actionable validation rules, and provides tested implementation patterns for engineers building automated QA pipelines.
GIS analysts, QA engineers, data stewards, and compliance officers working within Core Spatial QC Fundamentals & Standards encounter topology problems at every scale: overlapping land parcels that create double-ownership, gaps in hydrological basin coverage that break watershed models, and dangling utility network nodes that stall routing algorithms. Without systematic topology enforcement, these problems accumulate silently until they surface as analytical failures or regulatory audit findings.
Prerequisites
Before running topology checks, confirm every item in this checklist. Skipping any one of them is the most common source of false positives and pipeline bottlenecks.
- GeoPandas 0.14+ / PostGIS 3.2+ / GDAL 3.4+. Topology predicates on older versions of GEOS (the underlying C library) contain edge-case bugs in
ST_TouchesandST_Covers. Pin your dependency manifest. - Consistent CRS, projected not geographic. All layers must share the same projected coordinate reference system (CRS) — not WGS 84 degrees. Topology operations rely on metric coordinate alignment; mixing systems introduces phantom gaps and overlaps. Before executing predicate checks, ensure CRS normalisation and decimal precision standards have been applied to all input layers.
- Clean single-part geometries. Explode
MULTI*geometries, repair null rings, and enforce 2D dimensionality before topology evaluation. Passing malformed geometries to spatial join predicates causes cascading errors that are hard to trace. The geometry validity checks for vector data page documents the pre-processing routines required at this stage. - Defined snap tolerance. Survey-grade data typically uses
0.001m; digitized boundaries from aerial imagery work at0.1–0.5m. Document and version-control the tolerance alongside the rule set — it directly controls which violations are real versus floating-point noise. - Attribute-driven rule mapping. Topology rules are rarely universal: “must not overlap” applies to zoning districts but not to road-rail crossings. Attribute values (
land_use_type,jurisdiction_code,feature_class) must be readable before the rule matrix executes, so that predicates can be scoped correctly. - Data volume estimate. Self-join operations scale as O(n²) without indexing. Datasets above roughly 100 000 features should use spatial indexes (
gdf.sindexin GeoPandas, GiST in PostGIS) or be partitioned by administrative boundary before full predicate evaluation.
Conceptual Foundation
The DE-9IM model
The OGC topology model is grounded in the Dimensionally Extended nine-Intersection Model (DE-9IM), defined in ISO 19125 / OGC Simple Features Access. DE-9IM characterises any spatial relationship between two geometries A and B by examining the intersections of three regions — interior (I), boundary (B), and exterior (E) — for each feature, producing a 3×3 matrix of intersection dimensions:
| I(B) | B(B) | E(B)
-------+------+------+------
I(A) | ? | ? | ?
B(A) | ? | ? | ?
E(A) | ? | ? | ?
Each cell contains the dimension of the intersection: F (false / empty), 0 (point), 1 (line), or 2 (area). Named predicates are syntactic sugar for specific DE-9IM patterns:
| Predicate | DE-9IM pattern | Spatial meaning |
|---|---|---|
Equals |
T*F**FFF* |
Features are geometrically identical |
Touches |
FT******* |
Boundaries meet but interiors do not |
Overlaps (polygon) |
T*T***T** |
Interiors share area; neither contains the other |
Contains |
T*****FF* |
A’s interior wholly contains B |
Covers |
T*****FF* or *T****FF* |
A covers B including boundary cases |
Crosses (line/polygon) |
T*T****** |
A’s interior crosses B’s boundary |
Disjoint |
FF*FF**** |
No intersection of any kind |
Understanding these patterns lets you write rules precisely. ST_Overlaps will not flag two parcels that share only a boundary edge — that is a Touches relationship and is topologically valid for adjacent parcels. Only ST_Overlaps returns true when their interiors genuinely intersect, which is the violation you want to catch.
Topology rules as business logic
Topology rules translate business and regulatory requirements into spatial predicates. The most common rules and their contexts:
- Must Not Overlap — two features of the same class cannot share interior space. Mandatory for zoning districts, tax parcels, ecological management units.
- Must Not Have Gaps — a contiguous coverage must contain no unassigned voids. Essential for soil surveys, hydrological basins, and municipal boundary mosaics.
- Must Be Covered By — features in a child layer must fall entirely within a parent layer. Used for infrastructure placement (e.g., manholes within road corridors, building footprints within land parcels).
- Boundary Must Not Overlap — shared edges between adjacent features must align exactly, preventing sliver polygons and ensuring clean spatial joins.
- Must Not Self-Intersect — individual geometries cannot cross themselves. This bridges geometry validity and topology: a self-intersecting ring breaks both single-feature validity and the adjacency relationships that depend on it.
Step-by-Step Implementation
Step 1 — Prepare and normalise inputs
Load source data with explicit CRS projection, explode multi-part geometries, and repair invalid rings using make_valid. Store the normalised layer in memory or as a staged table before any predicate is run.
import geopandas as gpd
from shapely.validation import make_valid
def prepare_for_topology(path: str, target_epsg: int = 27700) -> gpd.GeoDataFrame:
"""
Load, project, explode, and repair a vector layer before topology checks.
EPSG:27700 (British National Grid) is projected in metres — suitable for snap tolerance in metric units.
"""
gdf = gpd.read_file(path, engine="pyogrio")
# Drop null / empty geometries immediately
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy()
# Normalise to a projected CRS
gdf = gdf.to_crs(epsg=target_epsg)
# Explode MULTI* to single-part for predicate compatibility
gdf = gdf.explode(index_parts=False).reset_index(drop=True)
# Repair invalid rings (make_valid preserves topology where possible)
gdf["geometry"] = gdf["geometry"].apply(
lambda g: make_valid(g) if not g.is_valid else g
)
return gdf
Verification: assert gdf.geometry.is_valid.all(), "Invalid geometries remain after make_valid".
Step 2 — Apply snap tolerance
Snap near-coincident vertices to eliminate sub-millimeter gaps caused by digitization drift. This must happen after geometry repair but before any predicate evaluation. Use shapely.ops.snap for GeoPandas or ST_Snap in PostGIS.
from shapely.ops import snap as shapely_snap
def apply_snap_tolerance(
gdf: gpd.GeoDataFrame,
reference: gpd.GeoDataFrame,
tolerance: float = 0.001
) -> gpd.GeoDataFrame:
"""
Snap gdf geometries to the reference layer within tolerance (metres).
Applied before topology predicates to suppress floating-point gap violations.
"""
ref_union = reference.geometry.union_all() # GeoPandas 0.14+ API
gdf = gdf.copy()
gdf["geometry"] = gdf["geometry"].apply(
lambda g: shapely_snap(g, ref_union, tolerance)
)
return gdf
For PostGIS, the equivalent is:
-- Snap parcel boundaries to a reference layer within 1 mm
UPDATE parcels p
SET geom = ST_Snap(p.geom, r.geom, 0.001)
FROM reference_layer r
WHERE ST_DWithin(p.geom, r.geom, 0.001);
Verification: Check that ST_IsValid or shapely.is_valid still returns true for all records after snapping.
Step 3 — Detect overlapping polygons (Must Not Overlap rule)
The spatial self-join with predicate="overlaps" applies the OGC DE-9IM Overlaps pattern: it matches pairs whose interiors share area but neither contains the other. The function below handles deduplication and computes intersection area for severity triage.
import pandas as pd
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def validate_no_overlap(
gdf: gpd.GeoDataFrame,
id_col: str = "parcel_id"
) -> pd.DataFrame:
"""
Detect overlapping polygons within a GeoDataFrame using the OGC Overlaps predicate.
Returns a DataFrame of violation pairs with intersection area for severity classification.
"""
if gdf.empty:
logging.info("Empty dataset — skipping overlap check.")
return pd.DataFrame()
left = gdf[[id_col, "geometry"]].copy()
right = gdf[[id_col, "geometry"]].copy()
# sjoin predicate="overlaps" maps exactly to ST_Overlaps / DE-9IM T*T***T**
raw = gpd.sjoin(left, right, how="inner", predicate="overlaps")
if raw.empty:
logging.info("No overlap violations detected.")
return pd.DataFrame()
id_l = f"{id_col}_left"
id_r = f"{id_col}_right"
raw = raw.rename(columns={id_col: id_l, f"{id_col}_right": id_r})
# Remove symmetric duplicates: A overlaps B == B overlaps A
raw["pair_key"] = raw.apply(
lambda row: tuple(sorted([row[id_l], row[id_r]])), axis=1
)
violations = raw.drop_duplicates(subset=["pair_key"]).copy()
# Compute intersection area for downstream severity triage
geom_index = gdf.set_index(id_col)["geometry"]
violations["intersection_area_m2"] = violations.apply(
lambda row: geom_index[row[id_l]].intersection(geom_index[row[id_r]]).area,
axis=1
)
result = violations[[id_l, id_r, "intersection_area_m2"]].reset_index(drop=True)
logging.info("Overlap check complete: %d violation pairs found.", len(result))
return result
Verification: Run validate_no_overlap against a known test dataset containing one deliberate overlap; expect exactly one row returned with a non-zero intersection_area_m2.
Step 4 — Detect coverage gaps (Must Not Have Gaps rule)
Gaps in a coverage layer are areas that no feature claims. The canonical detection approach constructs the union of all features, then subtracts it from the bounding envelope to reveal unclaimed space.
from shapely.geometry import box
def validate_no_gaps(
gdf: gpd.GeoDataFrame,
min_gap_area_m2: float = 0.01
) -> gpd.GeoDataFrame:
"""
Identify gaps in a polygon coverage layer.
min_gap_area_m2 filters out sub-centimetre gaps from floating-point precision.
Returns a GeoDataFrame of gap polygons with their area.
"""
coverage_union = gdf.geometry.union_all()
envelope = box(*gdf.total_bounds)
gaps_geom = envelope.difference(coverage_union)
# Decompose MultiPolygon into individual gap polygons
if gaps_geom.is_empty:
return gpd.GeoDataFrame(columns=["geometry", "gap_area_m2"], crs=gdf.crs)
gap_parts = (
list(gaps_geom.geoms) if gaps_geom.geom_type == "MultiPolygon"
else [gaps_geom]
)
gaps_gdf = gpd.GeoDataFrame(
{"geometry": gap_parts}, crs=gdf.crs
)
gaps_gdf["gap_area_m2"] = gaps_gdf.geometry.area
gaps_gdf = gaps_gdf[gaps_gdf["gap_area_m2"] >= min_gap_area_m2].copy()
return gaps_gdf.reset_index(drop=True)
Verification: The total area of detected gaps plus the total area of features should equal the envelope area, within floating-point tolerance.
Step 5 — Execute via PostGIS for large datasets
For datasets above 500 000 features, shift predicate evaluation to PostGIS. The following materialized view detects overlapping parcels using native GiST indexing — dramatically faster than a Python spatial self-join at scale.
-- Create a GiST spatial index before running the overlap check
CREATE INDEX IF NOT EXISTS idx_parcels_geom ON parcels USING GIST (geom);
-- Materialized view of overlap violations for incremental refresh
CREATE MATERIALIZED VIEW parcel_overlap_violations AS
SELECT
a.parcel_id AS id_a,
b.parcel_id AS id_b,
ST_Area(ST_Intersection(a.geom, b.geom)) AS intersection_area_m2,
ST_Intersection(a.geom, b.geom) AS intersection_geom
FROM parcels a
JOIN parcels b
ON a.parcel_id < b.parcel_id -- eliminates symmetric duplicates
AND ST_Overlaps(a.geom, b.geom); -- OGC Overlaps predicate
Refresh the view nightly or after each ingestion batch:
REFRESH MATERIALIZED VIEW CONCURRENTLY parcel_overlap_violations;
Verification: SELECT COUNT(*) FROM parcel_overlap_violations; — compare against the Python baseline on a shared sample subset.
Step 6 — Classify violations by severity and route to remediation
Categorising and prioritising spatial errors by severity before routing is the step that transforms raw violation records into actionable work items.
def classify_overlap_severity(
violations: pd.DataFrame,
area_col: str = "intersection_area_m2"
) -> pd.DataFrame:
"""
Assign severity tiers to overlap violations based on intersection area.
Blocker: area > 1 m² (material boundary conflict)
Warning: 0.01–1 m² (likely digitization drift)
Informational: < 0.01 m² (sub-centimetre floating-point noise)
"""
def tier(area: float) -> str:
if area > 1.0:
return "blocker"
if area >= 0.01:
return "warning"
return "informational"
violations = violations.copy()
violations["severity"] = violations[area_col].apply(tier)
return violations
Push blocker and warning records to your ticketing system or dead-letter queue; informational records are logged but do not block pipeline progression.
Common Failure Modes & Fixes
| Symptom | Root cause | Fix |
|---|---|---|
TopologicalError: This operation could not be performed |
Input geometry is invalid (self-intersection, unclosed ring) | Run make_valid before predicate evaluation; see geometry validity checks |
| Hundreds of tiny sliver violations across a shared boundary | Snap tolerance too small for digitization precision of source data | Increase tolerance to match source positional accuracy; run ST_Snap pre-pass |
sjoin returns zero rows on a dataset known to have overlaps |
Predicate string misspelled or wrong; e.g. "overlap" instead of "overlaps" |
Check gpd.sjoin docs; valid predicates are intersects, contains, within, touches, crosses, overlaps |
| False overlap flags between roads and buildings | Rule applied to heterogeneous feature classes | Filter by feature_class attribute before self-join; use separate rule sets per class |
| Self-join stalls on 1 M+ features | Missing spatial index; O(n²) brute-force search | Build gdf.sindex (R-tree) in GeoPandas or GiST in PostGIS before the join |
| Gaps detected at envelope boundary only | Bounding box is wider than the true coverage extent | Clip the gap detection envelope to a reference boundary polygon before subtraction |
ST_Snap produces new invalid geometries |
Tolerance too large relative to feature width; vertex merges create degenerate rings | Reduce tolerance; validate again with ST_IsValid after snapping and repair any new invalids |
Performance & Scale Considerations
Spatial indexing is non-negotiable. Without an R-tree (gdf.sindex) in GeoPandas or GiST index in PostGIS, a self-join is an O(n²) full-table scan. For a 200 000-parcel dataset, that is 40 billion pairwise comparisons before the predicate even runs.
Two-phase filter pattern. Apply a fast bounding-box pre-filter first (the spatial index handles this), then run the exact DE-9IM predicate only on candidate pairs. GeoPandas sjoin does this automatically when a spatial index is present. In raw SQL, use && (bounding-box overlap operator) as a first gate before ST_Overlaps:
-- Two-phase pattern: bbox pre-filter then exact predicate
SELECT a.parcel_id, b.parcel_id
FROM parcels a
JOIN parcels b ON a.geom && b.geom -- fast bbox test
WHERE a.parcel_id < b.parcel_id
AND ST_Overlaps(a.geom, b.geom); -- exact DE-9IM test
Chunking for memory safety in Python. For datasets that exceed available RAM, partition by a grid or administrative boundary attribute and process each partition independently. Merge violation outputs at the end. The batch processing approach for large spatial datasets covers partitioning strategies in detail.
Moving to distributed execution. When a single PostGIS node is insufficient — typically above 10 million features — consider Apache Sedona (formerly GeoSpark), which distributes spatial predicates across Spark executors. Sedona implements the same OGC predicates via ST_Overlaps, ST_Touches, etc., so rule sets translate directly.
Gap detection memory ceiling. The union_all() call in gap detection materializes the entire union in memory. For very large coverage layers, replace it with a tiled approach: compute the union per tile, then subtract tile-level unions from their bounding boxes and union the resulting gaps at the end.
Integration with the Validation Pipeline
Topology validation sits in the rule-engine stage of the validation directed acyclic graph (DAG), downstream of CRS normalisation and geometry pre-validation, and upstream of error routing and audit archiving. The broader pipeline architecture is documented in Core Spatial QC Fundamentals & Standards.
The rule engine described in building rule engines with GeoPandas provides the execution harness that schedules topology rule sets, manages rule versioning, and passes validated GeoDataFrames to the predicate functions shown above. Topology checks should be modelled as named, configurable rule objects so they can be enabled, disabled, or tolerance-adjusted per dataset class without touching pipeline code.
For asynchronous, high-volume ingestion pipelines, topology validation tasks should be decoupled from the ingestion thread and enqueued as background jobs. The asynchronous validation workflows with Celery guide shows how to dispatch topology jobs to a worker queue and collect violation records into a central audit store.
CI/CD integration checklist:
- Pre-commit hook: run lightweight CRS and geometry validity checks on staged GeoJSON or GeoPackage files.
- Pull-request gate: execute the full topology rule matrix against the changed dataset in a PostGIS container. Block merge if any
blocker-severity violations appear. - Nightly scheduled job: refresh PostGIS materialized violation views across all production layers. Alert on-call if violation counts increase by more than a configured threshold.
- Deployment gate: verify topology violation counts against baseline snapshot before promoting a data release to production.
For domain-specific rule sets — such as cadastral surveying where boundary precision determines legal ownership — see how to define topology rules for cadastral maps for jurisdiction-specific tolerance thresholds and adjacency requirements.
Frequently Asked Questions
What is the difference between geometry validity and topology validity?
Geometry validity is a single-feature check: it confirms that a polygon is closed, non-self-intersecting, and properly oriented. Topology validity is a cross-feature check: it confirms that spatial relationships between features — adjacency, containment, coverage — conform to defined rules. A geometry can be individually valid yet still violate topology constraints. Two valid polygons can overlap when their business rules require mutual exclusivity. Always run geometry validity checks first; topology predicates applied to invalid geometries produce unreliable results.
Which DE-9IM predicate should I use to detect sliver polygons between adjacent parcels?
Use ST_Touches (or predicate="touches" in GeoPandas sjoin) to confirm that boundaries meet without interior intersection. A valid shared boundary satisfies Touches. Then compute ST_Area(ST_Difference(envelope, ST_Union(all_features))) to find unclaimed space. Slivers below your tolerance threshold are gap violations. Pair this with a ST_Snap pre-pass to eliminate sub-millimeter gaps that are digitization artifacts rather than genuine boundary mismatches.
When should I use GeoPandas versus PostGIS for topology validation?
GeoPandas is appropriate for datasets under roughly 500 000 features, for prototyping rule logic before operationalising, or when your team’s Python skills are stronger than their SQL skills. PostGIS is preferable for production datasets at that scale or above, offering GiST indexing, materialized views for incremental validation, and native parallelism via parallel query workers. For very large datasets — above 10 million features — Apache Sedona extends PostGIS-style predicates across a distributed Spark cluster.
How do I choose a snap tolerance that avoids false positives without masking real violations?
Set the tolerance to match the least-precise source in your dataset. Survey-grade parcel data typically uses 0.001 m (1 mm). Digitized boundaries from aerial imagery work at 0.1–0.5 m. A tolerance larger than your minimum feature width will merge distinct features; a tolerance smaller than the positional error of your source data will generate false gap violations. Document and version-control the tolerance value alongside the rule set — it is a first-class parameter of every topology validation run, not a hidden implementation detail.
Related
- Geometry Validity Checks for Vector Data — pre-process individual geometries before applying cross-feature topology rules
- Coordinate Reference System Precision Standards — CRS normalisation requirements that must be satisfied before topology predicates
- Categorising and Prioritising Spatial Errors — severity classification model for topology violation routing
- Building Rule Engines with GeoPandas — execution harness for scheduling and versioning topology rule sets
- How to Define Topology Rules for Cadastral Maps — jurisdiction-specific topology rule configuration