Building a Spatial Expectation Suite for GeoPackages
Great Expectations validates tabular data extremely well and knows nothing about geometry. A GeoPackage layer is tabular data with one column that carries almost all of the meaning, so a suite built from stock expectations validates everything except the part that matters. This guide sits within Great Expectations spatial expectations and covers assembling a suite that covers a GeoPackage layer completely.
The organising idea is that a spatial layer has four levels of contract, and each needs a different kind of expectation. Getting them in the right order is what makes a failing suite readable — a CRS failure explains a hundred extent failures, so the CRS check must come first and short-circuit the rest.
The four levels of a layer contract
Levels 1, 2, and 4 have no stock coverage. That is the whole reason a spatial suite needs custom work rather than a configuration file.
Step-by-step implementation
Step 1 — Load the layer as a validatable batch
Great Expectations validates a pandas frame; a GeoPackage layer becomes one via GeoPandas, with the geometry preserved in a column the custom expectations understand:
import geopandas as gpd
import great_expectations as gx
def load_layer(path: str, layer: str) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path, layer=layer)
# Keep WKB alongside the geometry so stock expectations can address it too.
gdf["_geom_wkb"] = gdf.geometry.to_wkb()
return gdf
context = gx.get_context()
datasource = context.sources.add_pandas("parcels_source")
asset = datasource.add_dataframe_asset("parcels")
Carrying a WKB column is a small trick with real payoff: stock expectations such as expect_column_values_to_not_be_null and expect_column_values_to_be_unique then work on geometry without any custom code, and duplicate-geometry detection becomes a one-line stock expectation rather than a custom one.
Step 2 — Check the file and layer before anything else
These run outside the expectation suite because they decide whether a suite can run at all:
import pytest
from pyogrio import list_layers
EXPECTED_LAYER = "parcels"
EXPECTED_EPSG = 27700
EXPECTED_GEOM = "MultiPolygon"
def test_geopackage_contains_the_expected_layer(gpkg_path):
layers = {name for name, _ in list_layers(gpkg_path)}
assert EXPECTED_LAYER in layers, f"layers present: {sorted(layers)}"
def test_layer_declares_the_expected_crs_and_type(gpkg_path):
gdf = gpd.read_file(gpkg_path, layer=EXPECTED_LAYER, rows=1)
assert gdf.crs.to_epsg() == EXPECTED_EPSG, f"declared {gdf.crs.to_epsg()}"
types = set(gdf.geom_type)
assert types <= {EXPECTED_GEOM}, f"unexpected geometry types: {types}"
Reading with rows=1 makes the CRS and type check nearly instant on a large file, which matters because these are the checks you want running on every commit rather than nightly.
Step 3 — Assemble the suite in contract order
suite = context.add_expectation_suite("parcels_layer")
# Level 2 — the layer as a whole.
suite.add_expectation(gx.expectations.ExpectTableRowCountToBeBetween(
min_value=1_000, max_value=250_000,
))
suite.add_expectation(gx.expectations.ExpectTableColumnsToMatchSet(
column_set=["id", "uprn", "area_m2", "updated_at", "geometry", "_geom_wkb"],
))
# Level 3 — attributes, all stock.
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeUnique(column="id"))
suite.add_expectation(gx.expectations.ExpectColumnValuesToNotBeNull(column="id"))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeBetween(
column="area_m2", min_value=1.0, max_value=5_000_000.0,
))
suite.add_expectation(gx.expectations.ExpectColumnValuesToMatchRegex(
column="uprn", regex=r"^\d{1,12}$", mostly=0.99,
))
# Level 4 — geometry, custom.
suite.add_expectation(ExpectColumnGeometriesToBeValid(column="geometry"))
suite.add_expectation(ExpectColumnGeometriesToBeWithinExtent(
column="geometry", extent=(400_000, 100_000, 600_000, 300_000),
))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeUnique(
column="_geom_wkb", # duplicate geometry, for free
))
ExpectColumnValuesToMatchRegex with mostly=0.99 is worth pausing on. Real reference identifiers have exceptions, and an expectation that demands perfection on a column with a known 0.3% exception rate gets disabled rather than investigated. mostly is the mechanism for encoding a tolerance you have agreed with the data owner, and it should carry a comment saying who agreed to it.
Step 4 — Write the two custom expectations the suite needs
The full pattern for writing custom expectations is covered in writing custom geometry expectations; the two this suite requires are short:
from great_expectations.expectations import ColumnMapExpectation
import shapely
class ExpectColumnGeometriesToBeValid(ColumnMapExpectation):
"""Every geometry satisfies the Simple Features validity rules."""
map_metric = "column_values.geometry_valid"
success_keys = ("mostly",)
default_kwarg_values = {"mostly": 1.0}
class ExpectColumnGeometriesToBeWithinExtent(ColumnMapExpectation):
"""Every geometry falls inside the layer's declared extent."""
map_metric = "column_values.geometry_within_extent"
success_keys = ("extent", "mostly")
default_kwarg_values = {"mostly": 1.0}
Both are column-map expectations, which is the right base class because both evaluate per feature and produce a count of failures plus a sample of the offending rows. That sample is what makes a validation result actionable — a suite that reports “3,412 invalid geometries” and nothing else sends someone back to the data with no starting point.
Step 5 — Version the suite alongside the schema it describes
An expectation suite is a specification of what a layer must contain, which makes it the same kind of artefact as a database migration: it changes when the data’s contract changes, and every change should be attributable. Great Expectations stores suites as JSON, so committing them to the repository alongside the code that produces the layer costs nothing and gives the suite a history.
What that history buys becomes clear the first time a suite starts failing after an upstream release. The question is always whether the data got worse or the expectation got stricter, and a commit log answers it in seconds. Without one, the investigation starts by reconstructing what the suite used to say from memory.
Two conventions make the history readable. First, name the suite after the layer and its schema version — parcels_v3 rather than parcels — so a schema change produces a new suite rather than an edit to the old one, and both remain runnable. Second, never edit a suite in the same commit as a data fix. A commit that loosens an expectation and repairs the data at once makes it impossible to tell afterwards which of the two resolved the failure, and that ambiguity is exactly what turns a tolerance into a permanent one.
Reading the result
A validation result is a JSON document, and the part worth automating on is results[].result.partial_unexpected_index_list — the sample of failing rows. Turning it into feature identifiers is what closes the loop between a validation run and a data fix:
def failing_ids(validation_result, gdf, limit: int = 20):
"""Map a validation result's unexpected indices back to feature ids."""
out = {}
for res in validation_result.results:
if res.success:
continue
idx = res.result.get("partial_unexpected_index_list") or []
expectation = res.expectation_config.type
out[expectation] = list(gdf.loc[idx[:limit], "id"])
return out
Handing a data owner twenty identifiers is a request they can act on. Handing them a JSON blob is not.
Deciding what belongs in the suite and what belongs in pytest
A team that adopts Great Expectations alongside an existing pytest suite has to answer a question the tools do not answer for them: which checks live where. Putting everything in both places doubles the maintenance and produces two failure reports that disagree.
The division that holds up is by audience. A Great Expectations suite produces a validation document intended for someone who owns the data — a data steward, an upstream supplier, an analyst — and its output is a report saying which rows failed which named expectation. A pytest suite produces a pass or fail intended for someone who owns the code, and its output is a stack trace. Checks whose failure means “the incoming data is wrong” belong in the expectation suite, because the person who must act on them is not reading CI logs. Checks whose failure means “our transformation is wrong” belong in pytest, because the person who must act on them is.
Applied to a GeoPackage layer, that puts the schema, value ranges, feature counts, validity rate and extent in the expectation suite, because all of them describe what arrived. It puts round-trip parity, CRS transformation accuracy and topology rules produced by your own processing in pytest, because those describe what your code did. The boundary is not always crisp, and where it is genuinely ambiguous, the question to ask is who receives the failure — that answers it almost every time.
Failure modes and edge cases
GeoPackage stores geometry as a binary blob with a header. Reading through GeoPandas handles it; reading through plain SQLite does not, and the resulting bytes are not WKB — they carry a GeoPackage-specific envelope prefix. Never build expectations directly against a SQLite connection to a GeoPackage.
The declared CRS and the actual coordinates can disagree. A layer’s gpkg_spatial_ref_sys entry is metadata, and nothing enforces that the coordinates match it. The extent expectation is what catches this — coordinates in EPSG:4326 declared as EPSG:27700 fall wildly outside a British National Grid extent, which is why the extent check must be present even when the CRS check passes.
Feature-count bounds go stale. A range of 1,000 to 250,000 encodes an assumption about the data’s size that will be wrong in a year. Prefer a bound relative to the previous run — a drop of more than 10% is a signal — over an absolute range that gets widened until it means nothing.
mostly hides a growing problem. A tolerance of 0.99 passes at a 1% failure rate whether the rate is stable or climbing. Record the actual unexpected fraction from each run so the trend is visible, in the way described in tracking geometry drift as a service level objective.
Large layers make validation slow. A validity check over a million polygons is minutes, not seconds. Sample deliberately for the fast gate and run the full suite nightly, and make the sampling explicit in the suite name so nobody mistakes one for the other.
Conclusion
Build the suite in contract order — file, layer, column, geometry — so the first failure names the cause rather than its consequences. Use stock expectations for everything they cover, add a WKB column so more of them apply than you would expect, and write custom column-map expectations for validity and extent so that failures come with the feature identifiers a data owner needs.
Related
- Great Expectations Spatial Expectations — the parent strategy this fits into
- Writing Custom Geometry Expectations in Great Expectations — the metric and expectation classes in full
- Wiring Great Expectations Checkpoints into pytest — running this suite as part of the test gate
- Validating Attribute Schemas with Great Expectations — the level-three checks in depth
- pytest-geo vs Great Expectations for Spatial Validation — deciding which tool owns which check