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.
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.
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.
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
- Declarative Validation Frameworks for Geodata — where Pandera sits among the alternatives
- Declarative Schema Validation with Pydantic — the per-record counterpart
- Integrating Great Expectations with GeoPandas — the same derived-column pattern in another framework