Choosing Spatial Testing Tools
More than one tool can enforce almost any spatial rule, and choosing badly is how teams end up with slow gates, duplicated checks, or validation that lives in the wrong layer of the stack. This decision framework sits beneath CI/CD spatial quality gates and gives a structured way to pick between the realistic options at the moment you write a gate: a schema-and-domain contract as a pytest assertion or a Great Expectations suite, a topology rule evaluated in-process with Shapely or pushed to PostGIS, and a spatial join in a test backed by an in-memory R-tree or a database GiST index. The right answer is rarely absolute — it depends on where the data already lives, how large the fixtures are, and whether the same rule must also run in production — so this framework is about matching the tool to those constraints rather than declaring a winner.
The three decisions below share one principle: put the check where the data already is, and prefer the tool whose failure output a reviewer can act on. Each decision has a full head-to-head guide — pytest-geo vs Great Expectations, Shapely vs PostGIS for topology, and R-tree vs GiST.
Decision Reference
| Decision | Choose the first when… | Choose the second when… |
|---|---|---|
| pytest + Shapely vs Great Expectations | A handful of imperative geometry assertions; developer-owned | A documented, data-docs-published contract over many columns |
| In-process Shapely vs PostGIS topology | Data is already in GeoDataFrames; fixtures fit in memory | Data lives in PostGIS; the same rule must gate ingestion |
| R-tree vs GiST index | The join is in-process against a fixture | The join is a database query you are also validating |
Contract Rules: pytest with Shapely, or Great Expectations
When the rule is “these fields exist, with these dtypes, in this domain, and this CRS,” both a pytest assertion and a Great Expectations for GIS suite can enforce it. Prefer plain pytest when there are a few rules that live naturally alongside the code and are read by developers. Prefer Great Expectations when the contract spans many columns, must be published as human-readable data docs for non-developers, and benefits from a declarative suite that a data owner can review without reading Python.
# pytest: direct and imperative — best for a few developer-owned rules
def test_parcel_contract(gdf):
assert {"parcel_id", "land_use"}.issubset(gdf.columns)
assert gdf.crs.to_epsg() == 3857
assert gdf["land_use"].isin({"residential", "commercial"}).all()
The trade-off in depth — expectations, data docs, and where each shines — is the subject of the dedicated pytest-geo vs Great Expectations comparison.
Topology Rules: In-Process Shapely, or PostGIS
A topology rule such as “no polygon self-intersects” or “no gaps between adjacent parcels” can run in-process with Shapely or server-side with PostGIS. The deciding factor is where the data lives and whether the rule must also gate database ingestion. In-process Shapely is the right call when your test already holds a GeoDataFrame and the fixture fits in memory. PostGIS is the right call when the data is already in the database, the fixture is too large to load, or the identical rule must run as an ingestion constraint so bad geometry never lands — a duplication the Shapely vs PostGIS guide unpacks with benchmarks.
# In-process: no database needed, fixture in memory
from shapely import is_valid
assert gdf.geometry.map(is_valid).all()
-- Server-side: same rule, but gates ingestion and scales past memory
SELECT id FROM parcels WHERE NOT ST_IsValid(geom);
Spatial Joins: R-tree, or GiST
When a test performs a spatial join — “every point falls inside exactly one zone” — the index behind it drives both speed and, subtly, result ordering. An in-memory R-tree (via Shapely’s STRtree or GeoPandas sjoin) is ideal when the join runs against an in-process fixture. A database GiST index is the right choice when the join is a query you are also validating for production, because testing against the same index type the database uses catches ordering and cardinality differences an in-memory index would hide. The performance and determinism trade-offs are measured in R-tree vs GiST index performance.
Common Failure Modes and Gotchas
- Duplicating a rule in two tools. Enforcing the same contract in both pytest and Great Expectations doubles maintenance and lets the two drift; pick the layer that owns the rule.
- Testing against the wrong index. Validating an in-memory R-tree join when production uses GiST can pass while the database query returns rows in a different order or cardinality.
- In-process checks on out-of-memory fixtures. Loading a million-row layer into Shapely to check validity OOMs the runner; push it to PostGIS.
- Choosing by familiarity, not fit. Reaching for pytest because the team knows it, when a non-developer data owner needs to review the contract, buries the rule where its audience cannot read it.
- Ignoring the production layer. A rule that must also gate ingestion belongs in the database; enforcing it only in-process leaves the ingestion path unguarded.
Conclusion
Choosing a spatial testing tool comes down to three questions — how many rules and who reads them, where the data lives, and where the join runs — each of which points cleanly at one option. Match the tool to those constraints and gates stay fast, rules live in exactly one place, and tests exercise the same engines production uses. For the gate architecture these tools plug into, return to CI/CD spatial quality gates.