Writing Custom Geometry Expectations in Great Expectations

Great Expectations ships no geometry-aware expectation, so validating that a WKT column contains valid, in-bounds geometry means writing a custom one. This guide sits beneath Great Expectations spatial expectations and walks the full path for a column-map expectation — subclassing the base, defining a Shapely-backed metric, registering it, and using it in a suite — using geometry validity as the worked example. The mechanics generalize to any per-row spatial predicate: once you can express “is this value valid geometry” as a custom expectation, the same skeleton covers bounds, CRS-per-row and dimensionality checks.

Why a custom expectation is required

A built-in expectation operates on scalar column values — numbers, strings, dates — and has no notion of a geometry. A validity check needs to parse each cell’s WKT into a Shapely geometry and apply is_valid, which is a domain operation the framework cannot supply. Great Expectations’ extension model handles exactly this: a ColumnMapExpectation maps a boolean function over a column, and a paired metric computes the per-row result. Writing both is what lets a spatial predicate live inside a declarative suite alongside the built-in attribute checks.

How the pieces fit together

The class structure looks heavier than the problem until you see what each layer is for. A metric answers “what is true of this column, row by row”; an expectation wraps that answer in a pass/fail judgement with a threshold; the examples serve simultaneously as the class’s own test suite and as its documentation. Splitting the work that way is what lets one metric back several expectations, and what makes the rendered documentation trustworthy — it is generated from cases that must pass for the class to load.

Metric, expectation and examples — what each layer does Three stacked layers. The base layer is a column map metric that applies a Shapely predicate to each value in the column and returns a boolean series; it knows nothing about pass or fail. The middle layer is the expectation class, which names that metric and applies a success criterion such as a maximum tolerated unexpected fraction, producing the pass or fail judgement and the structured result. The top layer is the examples block, which supplies concrete input data together with expected outcomes; the framework executes these as the class's own tests and renders the same cases as its documentation. A side panel shows a single metric supporting two distinct expectations with different thresholds, which is the reason the metric and the expectation are separate objects. ColumnMapMetricProvider applies a Shapely predicate row by row → boolean series knows nothing about pass or fail ColumnMapExpectation names the metric · applies mostly= threshold produces the judgement and the structured result examples input data + expected outcome run as tests, rendered as documentation Why they are separate expect_geometry_to_be_valid expect_geometry_valid_or_repairable two thresholds, one metric

Component reference

Component Role Base class / hook
Metric Computes per-row boolean ColumnMapMetricProvider + @column_condition_partial
Expectation Wraps the metric as a suite rule ColumnMapExpectation
map_metric Links expectation to metric class attribute
examples Test cases + docs class attribute
Registration Makes it importable module import in the suite

Step-by-step implementation

The example targets Great Expectations 0.18+ and Shapely 2.x, and produces expect_column_values_to_be_valid_geometry.

Step 1 — Define the Shapely-backed metric

The metric maps a parsing-and-validity function over the column, returning a boolean Series. It must never raise on bad input — an unparseable cell resolves to False.

from shapely import from_wkt, is_valid
from great_expectations.expectations.metrics import (
    ColumnMapMetricProvider, column_condition_partial,
)
from great_expectations.execution_engine import PandasExecutionEngine


def _valid_wkt(value: str) -> bool:
    try:
        return bool(is_valid(from_wkt(value)))
    except Exception:
        return False


class ColumnValuesAreValidGeometry(ColumnMapMetricProvider):
    condition_metric_name = "column_values.valid_geometry"

    @column_condition_partial(engine=PandasExecutionEngine)
    def _pandas(cls, column, **kwargs):
        return column.map(_valid_wkt)
from great_expectations.expectations.expectation import ColumnMapExpectation


class ExpectColumnValuesToBeValidGeometry(ColumnMapExpectation):
    """Expect each WKT value in the column to parse to a valid geometry."""

    map_metric = "column_values.valid_geometry"
    success_keys = ("mostly",)          # allow a tolerance, e.g. mostly=1.0

Step 3 — Provide examples that double as tests and docs

    examples = [{
        "data": {"wkt": [
            "POLYGON((0 0,1 0,1 1,0 1,0 0))",     # valid
            "POLYGON((0 0,1 1,1 0,0 1,0 0))",     # self-intersecting -> invalid
        ]},
        "tests": [{
            "title": "basic_validity",
            "include_in_gallery": True,
            "in": {"column": "wkt", "mostly": 1.0},
            "out": {"success": False, "unexpected_index_list": [1]},
        }],
    }]

Step 4 — Register and use it in a suite

Importing the module registers the expectation; then it is available on a batch like any built-in.

import great_expectations as gx
import expect_valid_geometry            # noqa: F401 — import registers it

ctx = gx.get_context()
batch = ctx.data_sources.pandas_default.read_dataframe(df)
batch.expect_column_values_to_be_valid_geometry("wkt", mostly=1.0)

Making the geometry column readable to the metric

The metric receives a pandas Series, and what is in that series decides how much work the metric has to do and how fast it runs. Three representations are common, and the choice has consequences well beyond style.

Shapely objects in an object-dtype column are the most convenient: the predicate is a direct method call and there is no parsing. The cost is memory — each object carries Python overhead — and that the column cannot be handed to a SQL execution engine at all.

WKB bytes are the best default for a suite that will grow. They are compact, they serialise cleanly into a store, and Shapely parses them quickly. The metric pays one parse per row, which is measurable but predictable.

WKT strings are the worst of the three for anything numeric and the most convenient for debugging, because a failure report containing readable geometry is much easier to act on. They are also the largest and the slowest to parse.

Representation Metric cost per row Memory Readable in a failure report Works with a SQL backend
Shapely object none — direct call highest no no
WKB bytes one parse lowest no yes
WKT string one parse, slower high yes yes

The pragmatic arrangement is WKB in the column and WKT in the failure report: parse once in the metric, and when a row fails, serialise just that row’s geometry to WKT for the unexpected_list. The sample in the report is small by definition, so the expensive readable form is only paid for on the handful of rows a human is going to look at.

WKB in the column, WKT in the report A flow from a geometry column holding WKB bytes into the metric, which parses each row once and evaluates the predicate. Passing rows flow into a counter only, contributing to the success fraction without retaining any geometry. Failing rows, a small minority, are serialised to WKT and placed in the unexpected sample carried by the validation result, so the report contains geometry a person can paste directly into a viewer. A footer notes that the costly readable representation is produced only for the rows a human will actually inspect. column: WKB compact, store-friendly metric parse once · run predicate passing rows counted, geometry discarded failing rows → WKT a handful, not a batch report paste into a viewer The expensive readable form is produced only for the rows a person will actually open — which is the whole trick.

Verification pattern

Great Expectations runs an expectation’s own examples as its test suite, so the fastest verification is the framework’s diagnostic runner, which exercises every declared example and reports coverage.

python -c "from expect_valid_geometry import ExpectColumnValuesToBeValidGeometry as E; \
print(E().run_diagnostics().to_dict()['tests'])"
# Each declared test should report a passing result

Choosing what the expectation should tolerate

The mostly parameter is the reason to write an expectation rather than an assertion, and setting it well takes a decision most teams postpone. Three values are defensible, and each says something different about the data’s contract.

mostly=1.0 — every row must satisfy the rule. Correct for invariants the pipeline itself guarantees: geometry non-null, CRS declared, geometry type as specified. A single violation means something is broken, not that the data is noisy.

A measured fraction below one — the rule is a target the producer has agreed to and has not yet fully met. This is the honest setting during a migration, and it must be accompanied by a date and an owner, or it becomes permanent.

mostly unset with the result recorded but not gated — the observation phase. The rule runs, the fraction is tracked, and nothing fails. This is the right entry point for any rule whose violation rate is unknown, and skipping it is why so many suites arrive already disabled.

Observation, agreed target, invariant A declining curve of unexpected fraction over time, divided into three phases. In the first phase the fraction sits around four per cent and the rule is recorded only, with nothing failing. In the second phase a threshold line is drawn just above the curve and steps downward as the producer reduces the fraction, so the gate blocks regressions without blocking the current state. In the third phase the curve reaches zero and the threshold is set to require every row, making any violation a break rather than noise. A footer records that each transition should carry a date and a named owner. unexpected fraction time observe recorded, nothing fails agreed target threshold just above the curve, tightened as it falls invariant mostly=1.0 threshold measured fraction Each vertical transition is a decision with a date and an owner. Without them, phase two lasts forever and the suite documents an aspiration rather than a contract.

Failure modes and edge cases

  1. Metric that raises. If _valid_wkt lets an exception escape, one malformed cell aborts the batch; catch and return False so the row is simply unexpected.
  2. mostly misused. Setting mostly=0.99 silently tolerates 1% invalid geometry; use 1.0 for a hard gate unless partial validity is genuinely acceptable.
  3. Empty geometry. from_wkt("POLYGON EMPTY") parses and is technically valid, so an emptiness rule needs its own predicate, not the validity one.
  4. WKB vs WKT columns. A column of WKB bytes needs from_wkb; a validity expectation written for WKT silently fails every row on a WKB column.
  5. CRS ignored. Validity says nothing about coordinate system; pair this expectation with a CRS check, as the attribute schema validation pattern does.

Keeping the class maintainable

A custom expectation is a small library that other people will depend on, and the failure mode is not that it breaks — it is that nobody dares change it. Three habits keep it soft.

Keep the predicate in one place. The metric should call a single function that also exists as ordinary Python, testable without any of the framework’s machinery. When someone needs to debug a result, they can call that function directly in a shell instead of constructing a validator. It also means the same predicate can be reused in a pytest assertion without duplicating the logic.

Write the examples for the reader, not for coverage. Two well-chosen cases — one obviously valid, one obviously not, both small enough to read as literals — teach the class better than a dozen generated ones, and they are what the rendered documentation will show. Keep the exhaustive cases in a normal test file where they belong.

Version the class alongside the suites that use it. An expectation whose semantics change while suites still reference the old name produces results that mean two different things across a time series, which silently corrupts the trend data. Renaming on a semantic change is cheap and makes the discontinuity visible instead of hidden.

Conclusion

A custom geometry expectation is a metric plus an expectation class: a Shapely-backed per-row function that never raises, wrapped as a ColumnMapExpectation with examples that serve as both tests and documentation. With that skeleton, any spatial predicate becomes a first-class rule in a declarative suite. For the broader suite design, return to Great Expectations spatial expectations.