pytest-geo vs Great Expectations for Spatial Validation

Both a pytest suite built on Shapely and a Great Expectations suite can enforce the same spatial contract, and teams routinely agonize over which to standardize on. This comparison sits beneath choosing spatial testing tools and settles the decision on the axes that actually matter in a gate: who authors and reads the rules, whether the output must be a human-readable report, how the checks integrate with CI, and how each behaves as the rule set grows. The short version — imperative pytest for developer-owned geometry logic, declarative Great Expectations for broad column contracts published to non-developers — is only useful with the reasoning behind it, which is what this page provides.

The root difference: imperative vs declarative

pytest expresses validation as code: a function asserts a condition, and a failure is an exception with a traceback. Great Expectations expresses validation as data: a suite is a list of expectations over columns, and a failure is a structured result rendered into browsable data docs. That distinction cascades into everything else. Imperative checks are maximally flexible — any geometry predicate you can write in Shapely is one assertion away — but they are read by developers and their failures are tracebacks. Declarative expectations are more constrained but self-documenting, reviewable by a data owner who never opens the code, and they produce a report as a first-class artifact.

Comparison reference

Axis pytest + Shapely Great Expectations
Rule style Imperative assertions Declarative expectation suite
Best for Arbitrary geometry predicates Column contracts, domains, types
Audience Developers Data owners + developers
Failure output Traceback Data docs, structured result
Custom geometry rules Native (any Shapely call) Needs a custom expectation
CI integration Exit code, native Checkpoint returns success flag
Overhead at small scale Minimal Suite + context boilerplate

Where pytest with Shapely wins

When the rule is a geometry predicate — “no polygon self-intersects”, “every route connects end to end”, “this buffer contains that point within tolerance” — pytest is the natural home, because the check is one Shapely call and lives beside the code that produces the geometry. It also wins when the rules are few and developer-owned, where the ceremony of a Great Expectations context is pure overhead.

# pytest: an arbitrary geometry predicate, no framework overhead
import pytest, geopandas as gpd
from shapely import is_valid, hausdorff_distance

@pytest.mark.geometry
def test_routes_match_reference_within_tolerance():
    got = gpd.read_file("out/routes.gpkg").geometry
    ref = gpd.read_file("tests/fixtures/routes_ref.gpkg").geometry
    for g, r in zip(got, ref):
        assert is_valid(g)
        assert hausdorff_distance(g, r) <= 0.01     # metres, projected CRS

This is exactly the tolerance logic from spatial tolerance thresholds, expressed imperatively.

Where Great Expectations wins

When the contract spans many columns — required fields, dtypes, value domains, CRS, null policies — and must be reviewed or consumed by people who do not read Python, Great Expectations wins by producing a declarative, self-documenting suite and a data-docs report. It shines as the shared contract between a data producer and consumer, where the expectation suite is the interface. Geometry-specific rules require a custom expectation, covered under Great Expectations spatial expectations.

# Great Expectations: a declarative column contract, rendered to data docs
import great_expectations as gx

ctx = gx.get_context()
batch = ctx.data_sources.pandas_default.read_dataframe(gdf)   # gdf: GeoDataFrame
batch.expect_column_values_to_not_be_null("parcel_id")
batch.expect_column_values_to_be_in_set("land_use", ["residential", "commercial"])
batch.expect_column_values_to_be_between("elevation_m", -430, 8849)

Integrating each into a CI gate

Both integrate cleanly, but differently. pytest gates on its exit code — the model used throughout GitHub Actions spatial testing. Great Expectations runs a checkpoint and gates on the returned success flag, which you convert to an exit code.

# ge_gate.py — convert a checkpoint result into a gate exit code
import sys, great_expectations as gx

result = gx.get_context().run_checkpoint(checkpoint_name="parcels_contract")
sys.exit(0 if result.success else 1)

Many mature pipelines use both: pytest for geometry predicates and Great Expectations for the column contract, each owning the layer it fits — the anti-pattern is enforcing the same rule in both, which the choosing spatial testing tools framework warns against.

Failure modes and edge cases

  1. Duplicating a rule in both. The same CRS check in a pytest test and an expectation drifts over time; assign each rule to exactly one tool.
  2. Great Expectations for a single predicate. Standing up a context and suite to assert one geometry rule is overhead a pytest one-liner avoids.
  3. pytest as the non-developer contract. Burying a column contract in test code hides it from the data owner who needs to review it; that audience needs data docs.
  4. Custom expectation for a trivial geometry rule. Writing a custom expectation to check validity, when a pytest is_valid assertion suffices, adds maintenance for no gain.
  5. Version-API drift. Great Expectations changed its API across 0.15→0.18+; pin the version so the suite syntax matches the runtime, mirroring the runtime pinning in containerized runtimes.

Conclusion

The choice is not about capability — both can enforce most spatial rules — but about ownership and audience: pytest with Shapely for developer-owned geometry predicates, Great Expectations for broad column contracts that non-developers review as data docs. Assign each rule to the tool whose model fits it, never both, and gates stay maintainable as the rule set grows. For the full decision framework, return to choosing spatial testing tools.