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.

The three families of spatial assertion and their representative predicates A root node, "Spatial assertions", branches to three family boxes. The topological and relational family resolves to contains, intersects and disjoint. The geometric and coordinate family resolves to vertices, bounds and area or length. The attribute and metadata family resolves to schema, CRS or SRID, and units. Spatial assertions Topological & relational how geometries relate Geometric & coordinate is the shape sound Attribute & metadata right fields and CRS contains · intersects disjoint vertices · bounds area / length schema · CRS / SRID units

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-61e-4 projected (m²)
Coordinate equality vertex distance absolute distance 0.0010.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.010.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 ε\varepsilon:

δarea=AobservedAexpectedAexpectedε\delta_{\text{area}} = \frac{\lvert A_{\text{observed}} - A_{\text{expected}} \rvert}{A_{\text{expected}}} \le \varepsilon

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:

dH(A,B)=max{supaAinfbBd(a,b), supbBinfaAd(a,b)}d_H(A, B) = \max\left\{ \sup_{a \in A} \inf_{b \in B} d(a, b),\ \sup_{b \in B} \inf_{a \in A} d(a, b) \right\}
Choosing topological, geometric or attribute assertions from what a rule constrains A spatial rule is classified by the question "what does the rule constrain?". The relationship branch leads to a topological assertion enforced by a DE-9IM pattern as an exact boolean with no epsilon. The shape-and-coordinate branch leads to a geometric assertion bounded by an area-delta or Hausdorff epsilon. The fields-CRS-units branch leads to an attribute assertion verified against an exact contract match. Spatial rule What does the rule constrain? relationship shape / coords fields / CRS Topological how geometries relate Geometric structure within tolerance Attribute fields, CRS and units DE-9IM pattern exact boolean · no epsilon area-delta / Hausdorff bounded by epsilon ε exact contract match dtype · SRID · domain

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.

Normalise, then compare — and what each stage prevents Four stages left to right. Stage one aligns the coordinate reference system and prevents the failure where a tolerance expressed in metres is compared against a distance measured in degrees. Stage two repairs or rejects invalid geometry and prevents relational predicates from returning results that are undefined on invalid input. Stage three snaps coordinates to a precision grid and prevents last-digit inequality that differs between engine builds. Stage four is the comparison itself, choosing a topological, metric or attribute assertion. Beneath each of the first three stages is the specific defect it removes, and a footer states that reordering the stages yields tests that pass for the wrong reason. NORMALISE COMPARE 1 · Align CRS resolve declared vs actual project to the budget’s unit 2 · Repair or reject is_valid + explain_validity quarantine what will not repair 3 · Snap precision set_precision(grid_size) grid in CRS units 4 · Assert topological · metric attribute · schema SKIP THIS STAGE AND YOU GET Unit mismatch 0.01 read as degrees ≈ 1 km of slack Undefined predicate contains() on a bowtie answer GEOS never promised Last-digit inequality green locally, red in CI differs by GEOS build Reordering is not a style choice. Snapping after comparison, or asserting before repair, produces a suite that passes for the wrong reason — the most expensive kind of green, because nobody goes looking for it.

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

  1. DE-9IM boundary touching. contains returns False when B lies on A’s boundary; if your rule means “contains or touches”, assert the explicit relate pattern T*****FF* rather than the bare predicate.
  2. Coordinate equality instead of tolerance. Writing geom_a == geom_b flags sub-nanometre float drift as failure. Use equals_exact(a, b, tolerance=...) or an area/Hausdorff bound.
  3. CRS unit mismatch. A 0.001 threshold means millimetres in EPSG:3857 but ~100 m in EPSG:4326. Reproject to a projected CRS before applying absolute distance thresholds.
  4. 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.
  5. Unpinned GEOS/PROJ. Different engine builds between PostGIS and Shapely produce divergent predicate results — pin both and validate cross-engine.
  6. Invalid geometry poisoning predicates. Self-intersecting or unclosed rings make downstream predicates undefined; run is_valid / make_valid as the first gate.
  7. 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.