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 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.

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.