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.
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)
Step 2 — Define the expectation and link the metric
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.
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.
Failure modes and edge cases
- Metric that raises. If
_valid_wktlets an exception escape, one malformed cell aborts the batch; catch and returnFalseso the row is simply unexpected. mostlymisused. Settingmostly=0.99silently tolerates 1% invalid geometry; use1.0for a hard gate unless partial validity is genuinely acceptable.- Empty geometry.
from_wkt("POLYGON EMPTY")parses and is technically valid, so an emptiness rule needs its own predicate, not the validity one. - 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. - 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.