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

Four contract levels, ordered so failures explain each other A spatial layer's contract is drawn as four stacked levels, ordered so that a failure at any level explains failures at the levels beneath it. The file level checks that the GeoPackage opens at all and contains a layer with the expected name. The layer level checks the declared coordinate reference system, the geometry type recorded in the metadata table, and the feature count. The column level checks attribute names, types, nullability and value ranges, all of which stock Great Expectations expectations already cover. The geometry level checks validity, containment within the expected extent, coordinate precision and dimensionality, none of which stock expectations can express. The ordering matters because a wrong coordinate reference system produces an extent failure on every single feature, so checking the reference system first converts a hundred confusing failures into one clear one. 1 · file opens as a GeoPackage; the expected layer name is present stock expectations: none apply 2 · layer declared CRS, geometry type, feature count stock expectations: row count only 3 · column names, types, nullability, value ranges stock expectations: fully covered 4 · geometry validity, extent, precision, dimensionality stock expectations: none apply Order is not cosmetic. A wrong CRS at level 2 makes every feature fail the extent check at level 4. Checking upward first turns a hundred failures into one, with the cause named in the message. Fail fast upward; report widely downward.

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.

Column map expectations versus table expectations for spatial checks Two Great Expectations base classes are compared for spatial use. A column map expectation evaluates one geometry at a time and reports both a count of failures and a sample of the specific rows that failed, which suits per-feature properties such as validity, containment within an extent, and coordinate precision. A table expectation evaluates the layer as a whole and reports a single pass or fail with no row detail, which suits layer-wide properties such as feature count, declared coordinate reference system and geometry type. Choosing a table expectation for a per-feature property is the common mistake, because it discards the example rows that make a failing result something an engineer can act on. ColumnMapExpectation evaluates one geometry at a time reports a failure count reports example failing rows supports a `mostly` tolerance validity · extent containment · precision TableExpectation evaluates the layer as a whole reports one pass or fail no row-level detail available `mostly` does not apply feature count · declared CRS · geometry type

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.

Split the checks by who receives the failure Checks are divided between two tools according to who must act on a failure. A Great Expectations suite produces a validation document intended for whoever owns the data — a steward, a supplier, an analyst — so checks describing what arrived belong there: schema, value ranges, feature counts, geometry validity rate and extent. A pytest suite produces a pass or fail intended for whoever owns the code, so checks describing what the pipeline did belong there: round-trip parity across formats, coordinate transformation accuracy, and topology rules the processing itself produces. Where the boundary is genuinely ambiguous, asking who receives the failure resolves it almost every time. expectation suite audience: whoever owns the data schema and column types attribute value ranges feature count against the last run geometry validity rate extent containment pytest suite audience: whoever owns the code round-trip parity across formats CRS transformation accuracy topology the pipeline produces the custom expectations themselves fixture generator behaviour Where the boundary is genuinely unclear, ask 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.