Great Expectations Spatial Expectations
Great Expectations gives spatial teams a declarative way to express a data contract — a suite of expectations over columns that validates, documents and reports in one artifact. This body of work sits within test data generation and mocking strategies and covers how to bend a tabular validation framework to geospatial data: expressing CRS, schema and domain rules as built-in expectations, writing custom expectations for the genuinely spatial checks (validity, topology, coordinate bounds) that no built-in covers, and wiring a checkpoint into CI so the suite becomes a gate. The reason this needs its own treatment is that Great Expectations has no native concept of a geometry column; making it validate spatial data well is a matter of knowing which rules map onto built-ins and which require the custom-expectation path detailed in writing custom geometry expectations.
The split in that diagram is the whole design: everything non-geometric is a built-in expectation, and everything genuinely spatial is a custom one. The sections below cover both halves, plus the checkpoint that turns the suite into a gate. Whether to reach for Great Expectations at all — versus plain pytest — is the subject of the pytest-geo vs Great Expectations comparison.
Expectation Coverage Reference
| Rule | Expectation | Built-in or custom |
|---|---|---|
| Required field present | expect_column_to_exist |
Built-in |
| Value domain | expect_column_values_to_be_in_set |
Built-in |
| Numeric range | expect_column_values_to_be_between |
Built-in |
| Non-null geometry | expect_column_values_to_not_be_null |
Built-in (on WKT column) |
| Declared CRS matches | custom expect_crs_to_be |
Custom |
| Geometry validity | custom expect_geometry_to_be_valid |
Custom |
| Within bounds | custom expect_geometry_within_bbox |
Custom |
| No self-overlap | custom, batch-level | Custom |
Built-In Expectations on the Attribute Columns
The attribute layer of a spatial contract — required fields, dtypes, value domains, numeric ranges — maps directly onto built-in expectations, because those columns are ordinary tabular data. Represent geometry as a WKT or WKB column and even a non-null geometry check becomes a built-in. This is the same attribute-contract discipline as attribute and metadata checks, expressed declaratively.
import great_expectations as gx
ctx = gx.get_context()
batch = ctx.data_sources.pandas_default.read_dataframe(df) # df has a wkt column
batch.expect_column_to_exist("parcel_id")
batch.expect_column_values_to_not_be_null("wkt")
batch.expect_column_values_to_be_in_set("land_use", ["residential", "commercial"])
batch.expect_column_values_to_be_between("elevation_m", -430, 8849)
Custom Expectations for the Geometry Column
The genuinely spatial rules — is the geometry valid, does it fall inside an expected bounding box, is its CRS the declared one — have no built-in, so they require a custom column-map expectation that parses the WKT and applies a Shapely predicate per row. The full mechanics of subclassing and registering one are in writing custom geometry expectations; the shape is a per-value function returning a boolean.
from shapely import from_wkt, is_valid
def _is_valid_wkt(value: str) -> bool:
try:
return bool(is_valid(from_wkt(value)))
except Exception:
return False # unparseable WKT fails the expectation, never raises
Deciding What Belongs in a Suite at All
Great Expectations is a data-quality tool, and a spatial pipeline has quality concerns that belong in three different places. Putting all of them in a suite produces a slow, brittle artefact that duplicates the test suite; putting none of them there wastes the one tool that produces a report a data steward can read.
The dividing line is who acts on the failure. An expectation should exist when the answer is “the team that produces the data” and when the finding is about this batch rather than about the code. A pytest assertion should exist when the answer is “the engineer who changed the code”. A constraint should exist when the answer is “nobody — it must simply be impossible”.
| Concern | Belongs in | Because |
|---|---|---|
| Attribute domain, nullability, ranges | Expectation suite | Producer acts; the result is a per-batch report |
| Feature counts within expected bounds | Expectation suite | Detects a truncated or doubled load |
| Geometry validity across a batch | Expectation suite via a custom expectation | Producer acts; the fraction matters |
| Tolerance arithmetic in your transform | pytest | Engineer acts; nothing to do with the batch |
| Predicate selection and CRS handling | pytest | A code property, constant across batches |
| Uniqueness of a primary key | Database constraint | Must be impossible, not merely detected |
| No two parcels overlap | Database constraint or topology check | A set property, not a column property |
The middle column is where most of the confusion sits, and one test settles it: would this assertion give a different answer on a different batch of data? If no, it is about the code and belongs in pytest. If yes, it is about the data and belongs in a suite. Geometry validity is the interesting case, because it is genuinely both — the rule is a code property, but the fraction of features that violate it is a batch property, which is why validity usually appears as a custom expectation reporting an unexpected fraction rather than as a pass/fail assertion.
How a Suite Earns Its Keep Over Time
The value of a suite is not the first run; it is the twentieth, when the validation results form a series. Three uses emerge that no assertion-based approach provides.
Trend detection. The unexpected fraction for a rule is a time series. A rule at a steady 0.4 per cent that jumps to 3 per cent has detected an upstream change even though the threshold — set at 5 per cent — was never breached. Alerting on the change rather than only on the threshold catches problems days earlier.
Negotiation with producers. A report showing exactly which rules a batch violated, with counts and samples, is a document two teams can work from. “Your data is bad” starts an argument; “4.1 per cent of features have a null surface_type, against a contract of 1 per cent, sample attached” starts a fix.
Onboarding a new source. Pointing an existing suite at a new dataset produces an immediate, structured account of how that dataset differs from what the pipeline expects. That is a day-one gap analysis for free, and it is far more reliable than reading a specification the producer wrote about their own data.
None of these survive if the validation results are discarded after each run. Persisting them — even as JSON in object storage keyed by batch and suite version — is the cheap step that converts a checking tool into a monitoring one.
Checkpoints as CI Gates
A suite becomes a gate when a checkpoint runs it and returns a success flag you convert to an exit code — the integration model from choosing spatial testing tools. The same run publishes data docs, so a failure is both a red gate and a browsable report a data owner can read.
import sys, great_expectations as gx
result = gx.get_context().run_checkpoint(checkpoint_name="parcels_spatial")
sys.exit(0 if result.success else 1) # non-zero blocks the merge
Pipeline Integration
Pin the Great Expectations version alongside the spatial stack — its API shifted across 0.15→0.18+, so an unpinned upgrade can break suite syntax exactly like an unpinned GEOS breaks a predicate, the concern the containerized GIS test runtimes work formalizes. Run the checkpoint in the same job that runs the rest of the spatial gate, and treat the generated data docs as an artifact so failures are reviewable without re-running.
Keeping the Gate Fast Enough to Keep
A checkpoint that takes twenty minutes will be moved to a nightly schedule, and a nightly gate does not block anything. Three techniques keep the pre-merge checkpoint in the range where it stays a gate.
Split by cost, not by subject. Column-level rules over a batch are cheap and run on everything. Geometry-level rules are expensive and run on a sample pre-merge and the full batch nightly. Splitting the suite in two along that line, rather than by “attributes” versus “geometry”, is what keeps the fast one fast as the schema grows.
Push the predicate to the store where possible. A suite executing against a database backend can express most column rules as SQL that the engine evaluates over an index, rather than pulling the batch into memory first. The difference on a multi-million-row table is minutes against seconds, and the rules read identically.
Sample deterministically, and say so. When a rule runs against a subset, the sample must be reproducible from the batch identifier, and the report must record that it was a sample and how large. An undocumented sample produces the worst of both worlds: a result nobody trusts and a runtime nobody understands.
Frequently Asked Questions
Do we need a data context, or can suites be built in code?
Both work, and the choice is about who edits them. A file-based context gives you suites as reviewable JSON, generated documentation, and a place to persist validation results — which is most of the value for a team with a data steward. Building suites in code is simpler for a pipeline whose rules are derived from a schema anyway, and avoids a directory structure nobody else touches. What you lose is the artefact a non-programmer can read, so decide on that basis rather than on ergonomics.
How do expectations coexist with the pytest suite without duplicating rules?
By keeping them on opposite sides of the batch-versus-code line and sharing the values rather than the rules. Thresholds live in one versioned configuration file that both read, so a tolerance is defined once. The rules themselves should not overlap: if a rule appears in both places, one of the two is testing the wrong thing, and it is almost always the pytest copy asserting on a fixture that happens to satisfy a data contract.
What is the right batch size for validation?
The unit your pipeline actually produces — one delivery, one partition, one day. Validating a smaller slice produces fractions that swing wildly and trend badly; validating an accumulation of several deliveries hides which one was bad. Matching the batch to the delivery also makes the result attributable, which is the property that lets you send the report to a producer.
Should a failed checkpoint block the pipeline or quarantine the batch?
Quarantine, in almost every case. Blocking leaves the pipeline stopped and the data unavailable, which turns a quality problem into an availability incident; quarantining routes the batch aside, keeps yesterday’s good data serving, and gives a human the artefact to look at. Reserve outright blocking for the small set of failures where serving stale data is safer than serving nothing.
How much of the suite should be generated from the schema?
As much as is mechanical — type, nullability, column presence, and any enumeration the schema already declares. Regenerating those on every schema change keeps them accurate at no cost. Everything that encodes judgement, particularly tolerated fractions and value ranges derived from domain knowledge, stays hand-written, and the two should live in separate files so a regeneration can never overwrite a decision.
Reading a Validation Result Well
The structured result is the artefact this whole approach exists to produce, and getting value from it is a skill worth stating explicitly. Four fields carry almost all of the signal.
The unexpected fraction, not the boolean. A rule that failed at 0.02 per cent and a rule that failed at 40 per cent are different problems with different owners, and the pass/fail flag flattens them into the same thing.
The unexpected sample. A handful of actual offending values usually identifies the cause outright — a single upstream system emitting a legacy code, a batch where one municipality used a different convention. Reading the sample is nearly always faster than reasoning about the rule.
The element count. A rule evaluated against far fewer rows than expected means the batch was truncated, or a filter upstream removed more than intended, and that is often a bigger problem than the rule’s own result.
The suite and expectation version. Without it, comparing today’s result against last month’s compares two different questions, and the trend that looked like improving data quality may just be a loosened threshold.
A report that surfaces those four for every failing rule turns validation output into something a producer can act on without a conversation. One that shows only a list of red rule names produces a meeting instead.
Common Failure Modes and Gotchas
- Expecting a geometry built-in to exist. There is no
expect_column_values_to_be_valid_geometry; validity, bounds and topology all require custom expectations. - Custom expectation that raises on bad input. A per-value function must return
Falseon unparseable WKT, not raise, or one malformed row aborts the whole suite. - Unpinned framework version. A minor Great Expectations upgrade can change the suite API; pin it so the syntax matches the runtime.
- CRS checked as a string. Comparing
crsas text ("EPSG:3857"vs"epsg:3857") gives false failures; normalize to the authority code before asserting. - Batch-level topology as a column-map. No-overlap is a relationship across rows, not a per-value check; implement it as a batch-level expectation, not a column-map one.
Where the suite lives in the repository
Treat the suite directory as source, not as generated output. It belongs beside the pipeline code, in review, with the same branch protection — because a change to a threshold is a change to what the system accepts, and reviewing it is the only moment anyone will notice a loosened rule. Storing suites in an object bucket that the pipeline reads at runtime removes that review step entirely, and the predictable result is a set of thresholds nobody remembers agreeing to.
The validation results, by contrast, are genuinely generated output and belong in storage rather than in the repository. Keeping the two apart — definitions in git, results in a bucket keyed by batch — is what makes the trend queryable without turning the repository into a data store.
A related discipline is to keep the number of suites small. One suite per dataset, versioned, is manageable; one per pipeline stage multiplies quickly and leaves nobody able to say which rules a given batch was actually checked against. Where different stages genuinely need different rules, express that as separate checkpoints referencing the same suite rather than as separate suites.
Conclusion
Making Great Expectations validate spatial data is a clean division of labour: built-in expectations for the attribute columns, custom column-map and batch-level expectations for the geometry, and a checkpoint that turns the whole suite into a CI gate with published data docs. Built this way, a spatial contract is enforced, documented and reviewable in one artifact. For the data-generation context this sits in, return to test data generation and mocking strategies.
One organisational note worth stating: give each suite a named owner, recorded in the suite metadata rather than in a wiki. A suite without an owner accumulates rules nobody can explain, and the first time one fires unexpectedly the whole file gets disabled because there is no one to ask whether the rule was right. Naming an owner costs a line and is the difference between a suite that is maintained and one that is merely present.
Related Pages
- Building a Spatial Expectation Suite for GeoPackages
- Wiring Great Expectations Checkpoints into pytest
- Writing Custom Geometry Expectations in Great Expectations
- Synthetic Vector Data Generation
- pytest-geo vs Great Expectations for Spatial Validation
- Attribute & Metadata Checks
- Parent: Test Data Generation & Mocking Strategies