Declarative Validation Frameworks for Geospatial Data

Imperative validation code — a growing pile of if statements, ad-hoc assertions, and per-column checks scattered across a script — is where most spatial quality control begins and where it quietly rots. The rules become impossible to review as a whole, drift out of sync with the data they guard, and cannot be run anywhere except inside the one job that owns them. Declarative validation flips this: you describe what valid data looks like in a single, versionable schema, and a framework decides whether a GeoDataFrame conforms. This guide shows GIS analysts, QA engineers, data stewards, and platform teams how to apply three declarative frameworks — Pydantic, pandera, and Great Expectations — to geospatial data, and it sits within Spatial Validation Tooling and Framework Selection as the framework-choice stage that complements imperative engines like Building Rule Engines with GeoPandas.


Declarative validation framework stack for geospatial data A GeoDataFrame batch on the left feeds three parallel declarative layers — a Pydantic model for feature properties and geometry, a pandera DataFrameSchema for column dtypes and vectorised checks, and a Great Expectations suite for batch expectations. All three call Shapely for geometry truth. Outputs are versioned schema files committed to version control and a single pass-or-fail continuous integration gate. GeoDataFrame batch Pydantic model properties + geometry pandera schema column dtypes + checks Great Expectations suite + Data Docs Shapely geometry truth Versioned schema files committed to Git CI pass / fail gate blocks bad merges

Prerequisites

Confirm each item before writing a schema. Declarative frameworks are only as reproducible as the versions that back them, and a floating dependency silently changes what “valid” means.

  1. Python 3.10+ with a locked declarative stack. Pin pydantic==2.6.*, pandera==0.18.*, great-expectations==0.18.*, geopandas==0.14.*, and shapely==2.0.*. Pydantic 2 is a hard requirement — the validator API and performance characteristics differ substantially from Pydantic 1, and every example here targets v2.

  2. A canonical coordinate reference system (CRS) decided in advance. Geometry validity and bounds checks are only meaningful in a known projection. Fix your target EPSG code before authoring geometry constraints; Coordinate Reference System Precision Standards covers how to pin and enforce it.

  3. A documented attribute schema. List every property column with its type, nullability, and permitted value range or enumeration. Declarative validation is a transcription of this document into code; without it you are guessing. The mapping work in Mapping Attribute Constraints to GeoJSON Schemas produces exactly this input.

  4. Shapely 2.0 verified. Run python -c "import shapely; print(shapely.__version__)" and confirm a 2.x result. Every geometry validator here calls the vectorised Shapely 2.0 array API rather than per-object methods.

  5. A version-control repository and a continuous integration / continuous delivery (CI/CD) runner. The entire value proposition of declarative validation is that schemas are committed and executed automatically. If schemas live only on a laptop, you have gained documentation but lost enforcement.


Conceptual Foundation

An imperative check answers “does this specific value pass this specific test right here?” A declarative schema answers “does this dataset conform to this contract?” The distinction is not cosmetic. When the contract is a first-class object, it can be serialized, versioned, diffed in a pull request, imported by other jobs, rendered as documentation, and executed by a test runner without any surrounding application. The validation logic stops being a property of one script and becomes a shared asset.

Three frameworks cover the spectrum of declarative spatial validation, and they are complementary rather than competing:

Pydantic validates records. It excels at a single feature — a GeoJSON Feature with its properties dictionary and geometry object. You declare a model class; Pydantic coerces incoming JSON to typed Python, aggregates every violation into a structured ValidationError, and gives you per-field error locations. Geometry validity enters through a custom field_validator that calls Shapely. This is the natural fit for validating a FeatureCollection element by element, or for guarding an application programming interface boundary where features arrive as JSON.

pandera validates DataFrames. It expresses column dtypes, nullability, uniqueness, and vectorised value checks as a DataFrameSchema, and validates a whole GeoDataFrame in one pass using pandas- and NumPy-level operations. Because it operates on columns rather than rows, it is the efficient choice for tabular attribute validation at volume, and its geometry-column Check runs shapely.is_valid across the entire GeoSeries at once.

Great Expectations validates batches and produces evidence. Beyond running checks, it maintains a catalogue of reusable expectations, a persistent history of validation runs, and rendered Data Docs — browsable HTML reports that a non-engineer steward or compliance officer can read. Its checkpoints slot into an orchestrator so validation becomes a scheduled, auditable pipeline stage rather than an inline assertion.

None of the three understands geometry on its own. Each delegates spatial truth to Shapely and, through it, to the underlying Geometry Engine Open Source (GEOS) library. The frameworks contribute coercion, aggregation, reporting, and reuse; Shapely contributes the Open Geospatial Consortium (OGC) definition of validity. This is why declarative geospatial validation is best understood as a thin, versionable contract layer wrapped around the same geometry primitives used by an imperative engine — the two share a foundation and differ only in how the rules are expressed. For the imperative side of that foundation, Implementing Shapely Geometry Checks in Python documents the exact is_valid, is_simple, and make_valid calls these schemas invoke.


Step-by-Step Implementation

Step 1: Pin the Stack and Establish a Test Fixture

Every subsequent step validates the same synthetic GeoDataFrame, so build it once with a deliberate mix of clean and broken features.

import geopandas as gpd
from shapely.geometry import Polygon

# One valid parcel, one self-intersecting bowtie, one out-of-range attribute
valid_poly = Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)])
bowtie     = Polygon([(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)])

gdf = gpd.GeoDataFrame(
    {
        "parcel_id": ["P-001", "P-002", "P-003"],
        "zoning":    ["residential", "commercial", "unknown"],
        "area_m2":   [1000.0, 2500.0, -5.0],
        "geometry":  [valid_poly, valid_poly, bowtie],
    },
    crs="EPSG:4326",
)

Verification: confirm the fixture holds exactly the defects you expect — one invalid geometry and one negative area.

import shapely
assert (~shapely.is_valid(gdf.geometry.values)).sum() == 1
assert (gdf["area_m2"] < 0).sum() == 1

Step 2: Declare Attribute Constraints with Pydantic

Model a single feature’s properties. Pydantic enforces types, ranges, and enumerations declaratively; a validator handles the geometry.

from enum import Enum
from pydantic import BaseModel, Field, field_validator
import shapely
from shapely.geometry import shape

class Zoning(str, Enum):
    residential = "residential"
    commercial  = "commercial"
    industrial  = "industrial"

class ParcelFeature(BaseModel):
    parcel_id: str = Field(pattern=r"^P-\d{3,}$")
    zoning: Zoning
    area_m2: float = Field(gt=0)
    geometry: dict  # raw GeoJSON geometry mapping

    @field_validator("geometry")
    @classmethod
    def geometry_must_be_valid(cls, v: dict) -> dict:
        geom = shape(v)
        if not shapely.is_valid(geom):
            reason = shapely.is_valid_reason(geom)
            raise ValueError(f"invalid geometry: {reason}")
        return v

Verification: feed the broken third feature and confirm Pydantic aggregates both the enum and the geometry failure into one error.

from pydantic import ValidationError
try:
    ParcelFeature(parcel_id="P-003", zoning="unknown",
                  area_m2=-5.0, geometry=gdf.geometry.iloc[2].__geo_interface__)
except ValidationError as exc:
    print(len(exc.errors()), "violations")   # 3: zoning, area_m2, geometry

The record-level detail here is what the sibling page Declarative Schema Validation with Pydantic expands into a full FeatureCollection walk with error aggregation.

Step 3: Declare a pandera DataFrameSchema for the GeoDataFrame

Where Pydantic works record-by-record, pandera validates the whole frame in one vectorised pass — the efficient path for attribute columns at volume.

import pandera as pa
from pandera import Column, Check, DataFrameSchema
import shapely

def geometry_is_valid(geoseries) -> "pd.Series":
    return shapely.is_valid(geoseries.values)

schema = DataFrameSchema(
    {
        "parcel_id": Column(str, Check.str_matches(r"^P-\d{3,}$"), unique=True),
        "zoning":    Column(str, Check.isin(["residential", "commercial", "industrial"])),
        "area_m2":   Column(float, Check.greater_than(0)),
        "geometry":  Column("geometry", Check(geometry_is_valid, element_wise=False)),
    },
    strict=True,      # reject unexpected columns
    coerce=True,      # coerce dtypes where safe
)

Verification: run in lazy=True mode so pandera collects every failing row rather than raising on the first, then inspect the failure cases.

try:
    schema.validate(gdf, lazy=True)
except pa.errors.SchemaErrors as exc:
    print(exc.failure_cases[["column", "check", "failure_case"]])
    # rows for zoning=unknown, area_m2=-5.0, and the invalid geometry

Step 4: Author a Great Expectations Suite with a Geometry Expectation

Great Expectations adds a persistent, human-readable layer. Register a custom expectation for geometry validity, then assemble a suite.

import great_expectations as gx
import shapely

context = gx.get_context()

# Register the GeoDataFrame as a batch via the pandas backend
data_source = context.data_sources.add_pandas("parcels_source")
asset = data_source.add_dataframe_asset(name="parcels_asset")
batch_def = asset.add_batch_definition_whole_dataframe("parcels_batch")

suite = context.suites.add(gx.ExpectationSuite(name="parcel_contract"))
suite.add_expectation(
    gx.expectations.ExpectColumnValuesToBeInSet(
        column="zoning", value_set=["residential", "commercial", "industrial"]
    )
)
suite.add_expectation(
    gx.expectations.ExpectColumnValuesToBeBetween(column="area_m2", min_value=0, strict_min=True)
)

# Geometry validity as a boolean helper column the suite can assert on
gdf["geometry_valid"] = shapely.is_valid(gdf.geometry.values)
suite.add_expectation(
    gx.expectations.ExpectColumnValuesToBeInSet(column="geometry_valid", value_set=[True])
)

Verification: validate the batch and confirm the suite reports the two bad rows and the invalid geometry, then build Data Docs.

batch = batch_def.get_batch(batch_parameters={"dataframe": gdf})
result = batch.validate(suite)
print(result.success)          # False — contract violated
context.build_data_docs()      # renders browsable HTML evidence

The full custom-expectation, checkpoint, and Data Docs treatment lives in Integrating Great Expectations with GeoPandas.

Step 5: Version the Schemas and Gate Them in CI

A schema only enforces anything once it runs automatically. Commit the schema files and execute them as a test.

# test_parcel_contract.py — collected by pytest in CI
import geopandas as gpd
from schemas import schema          # the pandera DataFrameSchema from Step 3

def test_parcels_conform_to_contract():
    gdf = gpd.read_file("data/parcels.gpkg")
    schema.validate(gdf, lazy=True)  # raises SchemaErrors -> non-zero exit -> red build

Verification: run pytest -q locally; a conforming dataset exits 0, and a dataset with any violation exits non-zero, which fails the continuous integration job and blocks the merge.

Step 6: Adapt Framework Errors to the Pipeline Contract

Declarative failures must speak the same language as the rest of the pipeline. Translate framework error objects into the shared ValidationResult shape used by the rule engine.

from dataclasses import dataclass

@dataclass
class ValidationResult:
    feature_id: str
    rule_name: str
    passed: bool
    failure_reason: str | None = None
    severity: str = "warning"

def pandera_errors_to_results(failure_cases, id_col="parcel_id", gdf=None) -> list[ValidationResult]:
    results = []
    for _, row in failure_cases.iterrows():
        idx = row["index"]
        feature_id = str(gdf.at[idx, id_col]) if gdf is not None and idx is not None else "unknown"
        results.append(ValidationResult(
            feature_id=feature_id,
            rule_name=f"schema::{row['column']}::{row['check']}",
            passed=False,
            failure_reason=str(row["failure_case"]),
            severity="blocker",
        ))
    return results

Verification: confirm each emitted result carries a rule_name, failure_reason, and severity so the downstream router treats a schema violation identically to a rule-engine failure.


Common Failure Modes & Fixes

Failure / symptom Root cause Remediation
Pydantic 1 syntax errors (@validator, class Config) Environment resolved Pydantic 1.x Pin pydantic==2.6.*; migrate to @field_validator and model_config
pandera reports SchemaError: column 'geometry' not in schema GeoDataFrame geometry dtype not declared Declare Column("geometry", ...); ensure the active geometry column name matches the schema key
Geometry check raises TypeError on None shapely.is_valid received a None geometry Filter geometry.notna() before the check, or return False for nulls inside the check function
Great Expectations validates an empty batch as success=True Batch built from a filtered-away DataFrame Assert len(batch) > 0 before validating; add ExpectTableRowCountToBeBetween with a floor
All geometry rows fail after read Source CRS lost or geometry stored as WKB strings, not Shapely objects Confirm gdf.crs is not None and geometry dtype is geometry, not object; parse WKB with shapely.from_wkb
pandera passes locally, fails in CI Floating dependency versions differ between machines Commit a lockfile; pin every schema-relevant package exactly
Enum coercion silently passes bad values Check.isin compares against a stale value set Generate the value set from a single shared constant imported by every schema
Data Docs show stale results Docs not rebuilt after the latest run Call context.build_data_docs() in the checkpoint action list so evidence regenerates every run

Performance & Scale Considerations

Prefer column-level validation for volume. pandera and the boolean-column pattern in Great Expectations both run geometry validity as a single shapely.is_valid(array) call over the whole GeoSeries, which dispatches to GEOS at the C layer. Pydantic, by contrast, instantiates and validates one model per record; for a million-feature collection that per-object overhead dominates. Reserve Pydantic for boundary validation of incoming records and streaming feeds, and use pandera for whole-dataset attribute-and-geometry contracts.

Validate geometry once, reuse the result. Computing shapely.is_valid is the most expensive part of a spatial schema. Materialise a geometry_valid boolean column once and let every framework assert against it rather than recomputing validity inside three separate checks.

Fail lazily, then aggregate. Both pandera (lazy=True) and Great Expectations collect all failures in one pass. Prefer this over eager, first-failure raising: a single full validation pass over a large GeoDataFrame is far cheaper than re-running validation after each individual fix.

Push past single-node limits deliberately. These frameworks operate in memory. Above roughly two million features, validate partitions rather than the whole frame — pandera schemas apply cleanly to Dask-GeoPandas partitions, and Great Expectations supports a Spark backend. The partitioning strategy mirrors the rule-engine scale-out described in Building Rule Engines with GeoPandas; the schema simply rides along on each partition.


Integration with the Validation Pipeline

Declarative schemas occupy the contract-gate position at the front of the validation directed acyclic graph (DAG). They run immediately after ingestion and before the imperative rule engine, rejecting structurally non-conforming data early and cheaply so the more expensive relational checks only ever see well-formed input.

Ingestion contract. The schema layer expects a GeoDataFrame with a non-null CRS, a geometry column of dtype geometry, and column names matching the schema keys. Enforce these at the ingestion boundary; a strict=True pandera schema turns an unexpected extra column into an explicit failure rather than a silent downstream surprise.

Output contract. Convert framework errors into the shared ValidationResult schema, as Step 6 shows, so a schema violation flows into the same routing and prioritisation machinery as any rule-engine failure. This lets Categorizing and Prioritizing Spatial Errors score a declared-contract breach and a topology breach on one scale.

Division of labour with the rule engine. The schema owns structure and single-feature validity; the rule engine owns cross-feature and business logic — overlaps, connectivity, sliver detection, externally sourced anomaly scores. Keeping the boundary crisp prevents duplicated logic: a constraint expressible as a type, range, enumeration, or per-feature predicate belongs in the schema, and everything relational belongs in the engine described in Building Rule Engines with GeoPandas.

CI/CD placement. Because schemas are versioned files, run them in the same continuous integration job that tests application code. A schema change and the data change that motivates it land in one reviewable pull request, and the build fails the instant real data drifts from the declared contract — the earliest and cheapest place to catch spatial quality regressions.


Frequently Asked Questions

Why move from imperative geometry checks to declarative schemas?

Declarative schemas are data, not control flow. A Pydantic model, pandera DataFrameSchema, or Great Expectations suite states what valid data looks like in one versionable artifact rather than scattering assertions through procedural code. That makes the contract diffable in code review, self-documenting for new stewards, and directly runnable in a continuous integration job. Imperative checks remain useful for logic a schema cannot express, but the schema becomes the single source of truth for structure, types, ranges, and enumerations.

Can Pydantic or pandera validate geometry, or only attributes?

Both can validate geometry, but neither understands geometry natively. You bridge to Shapely inside a validator. In Pydantic 2 you add a field_validator that parses the incoming coordinate structure and calls shapely.is_valid or a topology predicate, raising a ValueError on failure. In pandera you attach a Check to the geometry column whose function receives the GeoSeries and returns a boolean Series computed with shapely.is_valid. The framework handles coercion, aggregation, and reporting; Shapely supplies the spatial truth.

When should I use Great Expectations instead of pandera?

Use pandera when validation runs inline inside a Python job and you want a lightweight, code-first schema that raises immediately. Use Great Expectations when you need shareable Data Docs for non-engineers, a persistent history of validation runs, checkpoints wired into an orchestrator, and a catalogue of expectations reused across many datasets. The two are complementary: pandera guards function boundaries during development, Great Expectations provides the auditable, human-readable validation record for stakeholders and compliance.

Do declarative frameworks replace a GeoPandas rule engine?

No. Declarative schemas cover structural and single-feature constraints — types, ranges, enumerations, per-feature geometry validity and bounds. A rule engine still owns cross-feature and business logic: no two parcels overlap, road networks are connected, sliver detection, or externally sourced anomaly scores. In practice the schema runs first as a fast contract gate at the ingestion boundary, and features that pass proceed to the rule engine for the heavier relational checks.


Back to Spatial Validation Tooling and Framework Selection