Spatial Assertion Types Explained
A spatial assertion is the smallest deterministic unit of geospatial validation: a single, reproducible check that translates a spatial business rule into a pass/fail predicate. Getting this taxonomy right matters because spatial data fails in ways ordinary data does not — a geometry can be byte-identical yet topologically invalid, a coordinate can be “correct” yet drift by a metre after reprojection, and an attribute can satisfy its schema while silently carrying the wrong datum. This page sits directly beneath Geospatial QA Fundamentals & Architecture and classifies every spatial assertion into three families — topological/relational, geometric/coordinate, and attribute/metadata — then shows the version-correct Python and PostGIS code to implement each, how they slot into CI gates, and the spatial edge cases that break them. Choosing the right family for a rule is the difference between a suite that catches real regressions and one that drowns engineers in flaky, false-positive failures.
Each family answers a different correctness question. Topological assertions ask how do these geometries relate under the DE-9IM model, independent of coordinate precision. Geometric assertions ask is this geometry structurally sound and within tolerance of an expected shape, and are acutely sensitive to floating-point representation. Attribute and metadata assertions ask does this feature carry the right fields, types, CRS and units its downstream consumers assume. A mature suite layers all three, and — as the GIS test pyramid makes explicit — runs the fast deterministic ones first and the expensive spatial joins last.
Assertion Taxonomy and Tolerance Reference
The table below maps each assertion type to the tolerance strategy it requires, a typical threshold range, and the CRS units the threshold is expressed in. Threshold magnitude is meaningless without its unit: 0.001 is sub-millimetre in a projected CRS but roughly 100 metres in EPSG:4326 degrees. Always 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.
| Assertion type | Predicate / metric | Tolerance strategy | Typical threshold | Applicable CRS units |
|---|---|---|---|---|
| Containment / within | contains, within |
DE-9IM pattern, no numeric epsilon | exact boolean | any |
| Intersection / overlap | intersects, overlaps |
area-ratio guard against slivers | 0.1%–1% of feature area |
projected (m²) |
| Topological equality | equals, equals_exact |
snap tolerance + area delta | 1e-6–1e-4 m² |
projected (m²) |
| Coordinate equality | vertex distance | absolute distance | 0.001–0.01 m |
projected (m) |
| Shape similarity | hausdorff_distance |
relative distance bound | 0.5%–2% of extent |
projected (m) |
| Bounds / envelope | bounds, total_bounds |
absolute delta per axis | 0.01–0.1 m |
CRS units |
| Validity | is_valid, is_simple |
exact, GEOS makevalid | boolean | any |
| Schema / type | field presence + dtype | exact, contract-driven | exact | n/a |
| CRS / SRID | crs, ST_SRID |
exact authority code match | exact (e.g. EPSG:3857) |
n/a |
The two numeric metrics worth formalising are the area delta and the Hausdorff distance, because most geometric assertions reduce to one of them. The relative area delta enforces that an observed geometry’s area matches an expected value within a fraction
Shape-similarity assertions instead bound the directed Hausdorff distance between two coordinate sets, which captures the worst-case point-to-set displacement after a transformation:
Topological and Relational Assertions
Topological assertions validate spatial relationships — adjacency, containment, intersection, disjointness — independent of coordinate precision. They are expressed as boolean predicates from the GEOS engine via Shapely, and because they answer a yes/no question they generally carry no numeric epsilon. The subtlety is that GEOS evaluates them against the DE-9IM intersection matrix defined by the OGC Simple Features specification, so boundary-touching cases must be handled deliberately rather than discovered in production.
from shapely import contains, intersects, relate
# Boundary-robust containment: normalise invalid input first
clean = parcel.buffer(0) # Shapely 2.x: repairs self-touch rings
assert contains(clean, building) # strict interior containment
# DE-9IM pattern string for "A contains B including shared boundary"
assert relate(clean, building) == "T*****FF*"
The most common defect at this layer is a check that should be topological being written as a coordinate comparison, which makes it fail on harmless digitisation noise. Guard predicates by repairing input with buffer(0) (or make_valid for GEOS ≥ 3.10) so self-intersections and unclosed rings fail fast upstream rather than poisoning the predicate. These checks are cheap and deterministic, so they belong in the base of the test pyramid and run before any spatial join.
Geometric and Coordinate Assertions
Geometric assertions target the structural integrity of a primitive: vertex counts, ring orientation, coordinate bounds, area and length, and precision retention through serialization. These are the assertions most sensitive to IEEE-754 representation, so they are never written as exact equality — they always carry a tolerance derived from the area-delta or Hausdorff metrics above. A MultiPolygon should be checked for duplicate vertices and correct outer-ring orientation; a transformed feature should be checked for displacement within a stated bound rather than for bit-identical coordinates.
import math
from shapely import area, hausdorff_distance, equals_exact
# Area-delta assertion (relative epsilon from the reference table)
EPS_AREA = 1e-3
delta = abs(area(observed) - area(expected)) / area(expected)
assert delta <= EPS_AREA, f"area drift {delta:.2e} exceeds {EPS_AREA}"
# Shape similarity bounded by Hausdorff distance, in projected metres
assert hausdorff_distance(observed, expected) <= 0.01
# Tolerant topological equality (snap, not coordinate identity)
assert equals_exact(observed, expected, tolerance=1e-6)
Performance is a correctness concern here: naive Python iteration over millions of vertices stalls in garbage collection. Shapely 2.x exposes vectorised, NumPy-backed operations over GeoSeries, so geometric validation scales close to linearly when you operate on arrays rather than looping feature-by-feature. The exact epsilon values should be loaded from config, not hard-coded — see spatial tolerance thresholds for the per-CRS derivation, and the geometry validation patterns work for reusable validators.
Attribute and Metadata Assertions
Beyond geometry, an assertion suite must verify the semantic layer that governs interoperability. Attribute assertions confirm that features carry required business fields with the correct dtype and constrained value domains — land-use codes, elevation ranges, temporal validity windows. Metadata assertions enforce CRS, datum and unit conventions, the place where “silent” corruption most often hides: a layer reprojected without updating its declared SRID will pass every geometric check and still render in the wrong place.
import geopandas as gpd
gdf = gpd.read_file("parcels.gpkg")
# CRS / SRID contract — fail before any spatial maths runs
assert gdf.crs is not None, "missing CRS declaration"
assert gdf.crs.to_epsg() == 3857, f"unexpected SRID {gdf.crs.to_epsg()}"
# Attribute contract: required field, dtype, and value domain
assert {"parcel_id", "land_use"}.issubset(gdf.columns)
assert gdf["land_use"].isin({"residential", "commercial", "agricultural"}).all()
Keep attribute checks isolated from geometric processing so a topology failure never masks a missing field and vice versa — a scoping discipline detailed in scoping rules for map data validation. Expressing these as a versioned contract (via pydantic models or Great Expectations suites) lets spatial and non-spatial constraints live in one reviewable artifact; the attribute and metadata checks patterns extend this to cross-format pipelines.
Production-Grade Python Implementation
A real suite parameterises tolerances from config and asserts all three families together. The example below uses pytest 7+, Shapely 2.x and a fixture-loaded tolerance map so the same test runs identically on a laptop and a CI runner.
import json
import pytest
import geopandas as gpd
from shapely import area, hausdorff_distance, is_valid
@pytest.fixture(scope="session")
def tol() -> dict:
# Tolerances are config, not magic numbers — one source of truth.
with open("tests/fixtures/tolerances.json") 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_geometries_are_valid(parcels):
invalid = parcels[~parcels.geometry.map(is_valid)]
assert invalid.empty, f"{len(invalid)} invalid geometries"
def test_area_within_tolerance(parcels, tol):
expected = parcels["area_ref_m2"]
observed = parcels.geometry.area # vectorised, GEOS-backed
delta = (observed - expected).abs() / expected
assert (delta <= tol["area_rel"]).all()
def test_shape_displacement_bounded(parcels, tol):
ref = gpd.read_file("tests/fixtures/parcels_ref.gpkg").geometry
for got, want in zip(parcels.geometry, ref):
assert hausdorff_distance(got, want) <= tol["hausdorff_m"]
Because tolerances are injected, the same assertions can be tightened for staging and relaxed for noisy third-party feeds without touching test logic — and they read as a contract any reviewer can audit.
PostGIS and Database-Side Counterparts
Many spatial rules are cheaper to enforce inside the database, close to the data, and most Shapely predicates have a direct PostGIS analogue. Server-side assertions are valuable as ingestion gates and as a cross-engine check: GEOS and PostGIS can disagree at the boundary, so validating both surfaces the disagreement before it reaches production.
-- Topology + validity gate at ingestion
SELECT parcel_id
FROM parcels
WHERE NOT ST_IsValid(geom)
OR ST_SRID(geom) <> 3857; -- metadata + structural in one pass
-- Area-delta assertion mirroring the Python check
SELECT parcel_id,
abs(ST_Area(geom) - area_ref_m2) / area_ref_m2 AS area_delta
FROM parcels
WHERE abs(ST_Area(geom) - area_ref_m2) / area_ref_m2 > 0.001;
Driving these from a test runner with psycopg2 keeps database assertions in the same suite as the Python ones:
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"
Pin the spatial stack so the database and Python sides agree: differing GEOS builds between PostGIS and your local Shapely wheel are a frequent source of “passes locally, fails in CI” reports.
Pipeline Integration
Spatial assertions earn their keep only when they run automatically, deterministically and observably. Wire the three families into CI as ordered spatial quality gates — metadata and validity first (fast, fail-fast), then geometric tolerance checks, then any expensive cross-engine joins — so a missing CRS short-circuits the run before a million-vertex Hausdorff comparison ever starts. Pin libgeos and PROJ in the container image so the geometry engine is bitwise reproducible across runners; an unpinned PROJ grid shift will move coordinates by metres between builds and manifest as a flaky tolerance failure.
Serialize every assertion outcome to structured logs (JSON or Parquet) — the failing predicate, the observed value, the threshold, and the CRS — so DevOps can track failure rates and geometry degradation over time rather than re-reading console output. Reproducible fixtures are the other half of this: the mocking geospatial data for tests approach supplies the empty geometries, null-CRS tags and malformed WKB payloads these gates must survive, and the security boundaries in spatial QA controls keep a hostile WKT payload from exploiting the parser inside the runner.
Composing Assertions: Order Changes the Answer
Individual assertions are easy; the errors that survive review come from the order they run in. A spatial check is a small pipeline of its own — normalise, then compare — and getting the stages the wrong way round produces a test that is confidently wrong rather than merely failing.
Three orderings are worth committing to memory. Validity before relation: DE-9IM predicates are undefined on invalid input, so contains against a self-intersecting polygon returns a result GEOS never promised. Repair or reject first. CRS normalisation before any metric assertion: comparing a distance in degrees against a budget in metres is not a loose test, it is a meaningless one; align the CRS before the arithmetic, never after. Snapping before equality: two geometries that differ only in the last representable digit are equal for every practical purpose, and only a precision snap makes that judgement reproducible across engine builds.
A fourth ordering rule is less obvious and catches teams late: assert the cheap contract before the expensive geometry. A CRS or schema mismatch invalidates every geometric result that follows, so running the geometry suite first means burning minutes to produce failures that are all downstream of one cheap check. Ordering assertions by cost, with the contract layer first, turns a long red build into a fast, accurate one.
Frequently Asked Questions
When should an assertion use a tolerance rather than exact equality?
Whenever the value has passed through floating-point arithmetic — which in practice means any coordinate that has been projected, buffered, simplified, or read back from a format with limited precision. Exact equality is appropriate for identifiers, geometry types, CRS codes, and counts. If you find yourself writing an exact comparison on a coordinate, the value has either not been transformed at all or the test is about to become flaky.
Is a topological assertion always preferable to a coordinate one?
Usually, because topology is what downstream consumers actually depend on and it is stable under harmless vertex changes. The exception is when vertex identity itself is the contract — round-tripping through a format that must preserve coordinates exactly, or verifying that a simplification did not run. In those cases assert on the coordinates, and say in the test name why.
How do I assert something is unchanged without snapshotting the whole geometry?
Assert on a small set of invariants rather than on bytes: geometry type, vertex count, bounds within tolerance, area within a relative epsilon, and a topological equality check against the reference. That set catches every meaningful change while remaining readable in a diff, which is the property a serialised snapshot loses the moment it grows past a few lines.
Do DE-9IM pattern strings buy anything over the named predicates?
They buy precision at boundaries. The named predicates bundle common patterns, and the bundling is where the surprises live — most notably that contains is false when the inner geometry touches the outer boundary. When a rule genuinely cares about boundary-touching cases, spelling out the intersection matrix pattern documents the intent in a way contains never can, and it stops a future reader from “simplifying” it back to the wrong predicate.
What is the right response to an assertion that fails only in CI?
Treat it as an environment difference until proven otherwise, and compare engine versions before touching the assertion. The overwhelmingly common causes are an unpinned GEOS or PROJ build, a missing grid file present on the developer machine, and an unordered result that happened to be stable locally. Widening the tolerance to make CI green converts a reproducibility bug into a permanent loss of sensitivity.
Common Failure Modes and Gotchas
- DE-9IM boundary touching.
containsreturnsFalsewhen B lies on A’s boundary; if your rule means “contains or touches”, assert the explicitrelatepatternT*****FF*rather than the bare predicate. - Coordinate equality instead of tolerance. Writing
geom_a == geom_bflags sub-nanometre float drift as failure. Useequals_exact(a, b, tolerance=...)or an area/Hausdorff bound. - CRS unit mismatch. A
0.001threshold means millimetres in EPSG:3857 but ~100 m in EPSG:4326. Reproject to a projected CRS before applying absolute distance thresholds. - Snap-to-grid slivers. Union, buffer and difference operations spawn sub-centimetre fragments; guard intersection assertions with an area-ratio floor or they fail on artifacts, not defects.
- Unpinned GEOS/PROJ. Different engine builds between PostGIS and Shapely produce divergent predicate results — pin both and validate cross-engine.
- Invalid geometry poisoning predicates. Self-intersecting or unclosed rings make downstream predicates undefined; run
is_valid/make_validas the first gate. - Silent SRID drift. A reprojected layer with a stale declared SRID passes geometric checks and lands in the wrong place — assert the SRID explicitly at every ETL boundary.
Conclusion
Classifying spatial assertions into topological, geometric and attribute families gives an engineer a decision procedure: identify what a rule actually constrains, pick the family, then apply that family’s tolerance strategy and predicate. Layered this way — with tolerances loaded from config, engines pinned, and outcomes logged — spatial assertions become deterministic CI gates rather than flaky scripts. For how these primitives compose into a full validation hierarchy, return to Geospatial QA Fundamentals & Architecture.