Validating GeoDataFrames with Pandera

Pandera brings schema validation to the dataframe rather than to the record, which fits spatial batch work almost perfectly: a GeoDataFrame is a table with one awkward column, and everything except that column is exactly what Pandera was built for. The awkward column turns out to be manageable too — validity, geometry type and coordinate bounds all vectorise over a GeoSeries and drop cleanly into a Check. This guide builds a full schema for a parcel layer, uses lazy validation to get one complete report instead of a fix-and-rerun loop, adds the dataframe-level checks that no column can express, and maps the output onto the shared findings contract. It is the dataframe-first option among the frameworks compared in Declarative Validation Frameworks for Geodata.

Prerequisites

  • pandera 0.20+, geopandas 0.14+, shapely 2.0+, pandas 2.x.
  • A GeoDataFrame with a declared CRS. Bounds checks are meaningless otherwise, and the CRS check below assumes one exists to compare.
  • An error code registry, so schema failures map onto the same codes the rest of the pipeline emits.
  • A view on how much belongs here: Pandera covers the attribute contract and per-feature geometry checks. Cross-feature topology belongs below it, as set out in the parent topic.

Step-by-Step Procedure

Step 1 — Declare the attribute schema

# pandera_rules/schema.py
import pandera as pa
from pandera import Check, Column, DataFrameSchema

ZONING_CODES = ["R1", "R2", "C1", "C2", "M1", "MX"]

attribute_schema = DataFrameSchema(
    {
        "parcel_id": Column(
            str,
            checks=[
                Check.str_matches(r"^[A-Z]{2}-\d{6}$",
                                  error="parcel_id must be XX-000000"),
            ],
            nullable=False, unique=True, coerce=False,
        ),
        "zoning_code": Column(
            str,
            checks=[Check.isin(ZONING_CODES, error="zoning_code outside the codelist")],
            nullable=False,
        ),
        "assessed_value": Column(
            float,
            checks=[
                Check.greater_than_or_equal_to(0, error="assessed_value is negative"),
                Check.less_than(5e8, error="assessed_value implausibly large"),
            ],
            nullable=True, coerce=True,
        ),
        "record_date": Column(
            "datetime64[ns]",
            checks=[Check.less_than_or_equal_to(pa.Timestamp("2026-12-31"),
                                                error="record_date is in the future")],
            nullable=False, coerce=True,
        ),
    },
    strict="filter",       # unexpected columns are dropped, not fatal
    ordered=False,
    name="parcels_attributes",
)

Verification: coerce=True on assessed_value converts a numeric string to a float, which is usually helpful, and converts "N/A" to a failure rather than to NaN — which is what you want, and the opposite of what a bare pd.to_numeric with errors="coerce" would do.

A GeoDataFrame through a Pandera schemaChain of five stages: the frame enters, column checks run with coercion, geometry checks run as vectorised Check objects over the GeoSeries, dataframe-level checks see the whole frame, and lazy validation collects every failure into one failure-cases table.GeoDataFrametyped columns+ geometryColumn checkscoerce, rangepattern, isinGeometry checksvectorised overthe GeoSeriesFrame checksCRS, cross-columnuniquenessfailure_casestableone row perfailing value
Lazy validation is what makes the last box a table rather than an exception about the first bad row.

Step 2 — Geometry checks as vectorised Check objects

# pandera_rules/geometry_checks.py
import geopandas as gpd
import pandas as pd
import shapely
from pandera import Check, Column

JURISDICTION_BOUNDS = (400000, 100000, 560000, 220000)   # minx, miny, maxx, maxy


def _is_valid(series: pd.Series) -> pd.Series:
    return pd.Series(shapely.is_valid(series.to_numpy()), index=series.index)


def _is_polygonal(series: pd.Series) -> pd.Series:
    types = gpd.GeoSeries(series).geom_type
    return types.isin(["Polygon", "MultiPolygon"])


def _within_bounds(series: pd.Series) -> pd.Series:
    gs = gpd.GeoSeries(series)
    minx, miny, maxx, maxy = JURISDICTION_BOUNDS
    b = gs.bounds
    return ((b["minx"] >= minx) & (b["maxx"] <= maxx)
            & (b["miny"] >= miny) & (b["maxy"] <= maxy))


def _area_plausible(series: pd.Series) -> pd.Series:
    area = gpd.GeoSeries(series).area
    return (area > 5) & (area < 5_000_000)


geometry_column = Column(
    object,
    checks=[
        Check(_is_valid, element_wise=False,
              error="GEOM_VALID_001: geometry fails OGC validity"),
        Check(_is_polygonal, element_wise=False,
              error="GEOM_TYPE_001: expected Polygon or MultiPolygon"),
        Check(_within_bounds, element_wise=False,
              error="GEOM_BOUNDS_001: geometry outside the jurisdiction envelope"),
        Check(_area_plausible, element_wise=False,
              error="GEOM_AREA_001: parcel area outside the plausible range"),
    ],
    nullable=False,
    name="geometry",
)

Verification: element_wise=False is the important flag. Element-wise checks call the function once per row and are one to two orders of magnitude slower on a large frame; the vectorised form receives the whole series and returns a boolean series. Time both on 100,000 features once to see the difference for yourself.

Note the error strings carrying the error codes. Pandera’s error field flows into the failure report, so embedding the code there is what lets Step 5 map failures onto the pipeline contract without a second lookup table.

Step 3 — Validate lazily and read the failure cases

# pandera_rules/run.py
import geopandas as gpd
import pandera as pa
from pandera import DataFrameSchema

from pandera_rules.schema import attribute_schema
from pandera_rules.geometry_checks import geometry_column

parcel_schema = DataFrameSchema(
    {**attribute_schema.columns, "geometry": geometry_column},
    strict="filter",
    name="parcels",
)


def validate(gdf: gpd.GeoDataFrame):
    try:
        parcel_schema.validate(gdf, lazy=True)
        return {"passed": True, "failure_cases": None}
    except pa.errors.SchemaErrors as exc:
        return {"passed": False, "failure_cases": exc.failure_cases}

The failure_cases frame has one row per failing value with these columns:

column meaning
schema_context Column or DataFrameSchema — where the check was attached
column the column that failed
check the check’s description or the error string
check_number index of the check within that column
failure_case the offending value
index the dataframe index of the failing row

Verification: len(failure_cases) counts failing values, not failing features — a feature failing three checks contributes three rows. That distinction matters for any metric derived from the output, and it is the most common misreading of a Pandera report.

Step 4 — Dataframe-level checks

# pandera_rules/frame_checks.py
import geopandas as gpd
import pandas as pd
from pandera import Check, DataFrameSchema


def crs_is_canonical(df) -> bool:
    crs = getattr(df, "crs", None)
    return crs is not None and crs.to_epsg() == 27700


def commercial_needs_permit(df) -> pd.Series:
    """Cross-column rule: C-class zoning requires a permit reference."""
    needs = df["zoning_code"].isin(["C1", "C2"])
    has = df.get("permit_ref").notna() if "permit_ref" in df else pd.Series(False, index=df.index)
    return ~needs | has


def no_duplicate_geometry(df) -> bool:
    wkb = gpd.GeoSeries(df["geometry"]).to_wkb()
    return not wkb.duplicated().any()


frame_checks = [
    Check(crs_is_canonical, error="CRS_MISMATCH_001: layer is not EPSG:27700"),
    Check(commercial_needs_permit, element_wise=False,
          error="ATTR_COMBO_001: commercial parcel without a permit reference"),
    Check(no_duplicate_geometry, error="GEOM_DUP_001: duplicate geometry in the layer"),
]

parcel_schema_full = DataFrameSchema(
    parcel_schema.columns, checks=frame_checks, strict="filter", name="parcels",
)

Verification: a dataframe-level check returning a scalar boolean reports the whole frame as failing, with no row detail; one returning a boolean series reports the failing rows. Prefer the series form wherever the rule is per-row in nature — commercial_needs_permit above — because a whole-frame failure tells the reviewer nothing about where to look.

Step 5 — Map failures onto the pipeline contract

# pandera_rules/to_findings.py
import re

CODE_RE = re.compile(r"^([A-Z]+_[A-Z]+_\d{3}):")


def to_findings(failure_cases, gdf, registry, layer_id: str) -> list[dict]:
    """Pandera failure cases -> the shared findings contract."""
    findings = []
    for row in failure_cases.itertuples():
        match = CODE_RE.match(str(row.check))
        code = match.group(1) if match else "ATTR_SCHEMA_001"
        entry = registry.require(code)

        feature_id = (str(gdf.loc[row.index, "parcel_id"])
                      if row.index in gdf.index and "parcel_id" in gdf else str(row.index))
        findings.append({
            "layer_id": layer_id,
            "feature_id": feature_id,
            "rule": code,
            "severity": entry["severity"],
            "dimension": entry["dimension"],
            "message": f"{row.column}: {row.check} (value={row.failure_case!r})",
        })
    return findings


def summarise(findings: list[dict]) -> dict:
    from collections import Counter
    by_rule = Counter(f["rule"] for f in findings)
    by_sev = Counter(f["severity"] for f in findings)
    return {"total": len(findings),
            "distinct_features": len({f["feature_id"] for f in findings}),
            "by_rule": dict(by_rule), "by_severity": dict(by_sev)}

Verification: distinct_features versus total is the check that keeps the metrics honest — the denominators discussed in Spatial Data Quality Metrics and Reporting are per feature, not per failing value.

Interpreting Results

Failure pattern Meaning Response
One check failing on nearly every row Wrong assumption in the schema, not bad data Verify the check before raising a finding
coerce failures on a numeric column Non-numeric placeholders such as "N/A" Fix upstream; do not silently coerce to null
Geometry validity failures clustered by index A batch loaded together Investigate the batch, not the features
Frame-level CRS check failing The whole layer is in the wrong system Nothing else in the report is trustworthy yet
Unique constraint on parcel_id failing Duplicate records, or a join fan-out upstream Check the loader before the data
Many failures, few distinct features A handful of very broken records Report per feature, not per value

The fourth row deserves emphasis: a CRS failure invalidates every bounds and area check in the same run, because those thresholds assume a coordinate system. Order the schema so the frame-level CRS check runs first and short-circuit if it fails.

Element-wise versus vectorised geometry checksBar chart of seconds to run a validity check over 500,000 features: a vectorised Check 1.4 seconds, an element-wise Check 96 seconds, and an apply-per-row lambda 141 seconds.vectorised Check1.4 s — element_wise=Falseelement-wise Check96 s — one Python call per rowapply(lambda)141 sIdentical results, identical schema. The flag is the difference between a suite that runs on every commit and one that does not.
The intuitive way to write a geometry check is the slow one — set element_wise to False.

Gotchas & Edge Cases

element_wise=True is a performance trap. It is the intuitive way to write a geometry check and it calls Python once per row. On 500,000 parcels the vectorised version finishes in a couple of seconds and the element-wise version takes minutes.

Where Pandera fits, and where it does notGrid of four validation concerns showing whether Pandera is the right tool: attribute contract, per-feature geometry, cross-feature topology and whole-layer CRS.Pandera?WhyAttribute contractyes — idealexactly what it was built forPer-feature geometryyes, via custom Checksvectorises over the GeoSeriesWhole-layer CRSyes, as a frame checkreads df.crs onceCross-feature topologynoloses per-feature reporting; use the spatial engineThree yeses and one clear no. Pushing topology into a dataframe schema produces slow per-row code that still misses violations.
Knowing the fourth row is a "no" is what keeps the declarative layer useful.

Pandera does not know about the CRS. It sees an object column. The CRS check must be a dataframe-level check reading df.crs, and it only works if the frame is still a GeoDataFrame — some pandas operations return a plain DataFrame and silently drop the CRS attribute.

strict=True fails on unexpected columns; strict="filter" drops them. For validation over third-party data, "filter" is usually right — you do not want an extra column from a supplier to fail the whole run — but it means your schema is not asserting the absence of surprises. Choose deliberately.

Lazy validation collects failures, not exceptions. A check that raises inside its function still aborts the run. Defensive coding inside custom checks — returning False rather than raising on odd input — is what keeps the lazy report complete.

Index alignment matters. failure_cases.index refers to the dataframe index, so a frame reset between validation and reporting breaks the mapping back to features. Validate and report against the same object.

Schemas belong in version control with the rules. A schema change is a rule change, and the same versioning discipline applies — see Writing Declarative Rule Configs in YAML for the same argument applied to configuration.

When to Escalate

  • Topology requirements arriving in a Pandera schema are a signal the layering has slipped. Cross-feature rules belong in the spatial engine, not in a dataframe schema — see the layer model in the parent topic.
  • Schema failures on a supplier feed that cannot be fixed need a contract conversation. A schema that is relaxed to accommodate bad data stops being a contract.
  • Performance problems after adding a check are almost always element_wise. Profile before adding infrastructure.
  • Disagreement about what a check should assert is a data-contract question for the steward rather than a code review comment, per Data Stewardship Roles and Responsibilities.

Frequently Asked Questions

Does Pandera understand geometry natively?

It understands the geometry column as an object-dtype column and validates whatever checks you attach to it. There is no built-in spatial predicate library, so validity, geometry type and bounds arrive as custom Check objects wrapping Shapely calls. That is less magical than it sounds and works well, because those checks vectorise over a GeoSeries.

Why is lazy validation important here?

Without it, Pandera raises on the first failing check and you learn about one problem per run. With lazy=True it collects every failure into a single SchemaErrors exception carrying a failure_cases dataframe — exactly the shape a validation report needs, and it turns a fix-and-rerun loop into a single pass.

Can Pandera do topology checks between features?

Only indirectly. Dataframe-level checks see the whole frame, so a self-join for overlaps is technically expressible — but you lose the per-feature failure reporting that makes Pandera useful, and the framework offers nothing over calling the predicate directly. Keep Pandera for the attribute and per-feature layers, and run topology in the spatial layer beneath it.

How does it compare to Pydantic for spatial data?

Pandera validates a whole frame column-wise, which suits batch validation of tabular spatial data and vectorises well. Pydantic validates one record at a time, which suits API payloads and streaming events. For a GeoDataFrame of half a million parcels, Pandera wins by a wide margin; for a single incoming GeoJSON feature, Pydantic does.


Related

Back to Declarative Validation Frameworks for Geodata