Declarative Schema Validation with Pydantic
You have a GeoJSON FeatureCollection arriving from an upload, an application programming interface, or a partner feed, and you need each Feature to satisfy a strict contract — typed properties, permitted enumerations, positive measures, and a geometrically valid shape — before it enters your pipeline. This page shows how to express that contract as a Pydantic v2 model, bridge to Shapely for geometry truth, and validate an entire collection with per-feature error aggregation. It is the record-level companion to the Declarative Validation Frameworks for Geospatial Data overview, and it targets in-memory collections up to the low hundreds of thousands of features.
Prerequisites
- Python 3.10+ — required for the
X | Yunion syntax used in field annotations. - Pydantic 2.6+ — every example uses the v2 API. Confirm with
python -c "import pydantic; print(pydantic.VERSION)"; a leading1.means the wrong major version is installed and@field_validatorandmodel_configwill not exist. - Shapely 2.0+ — the geometry validator calls
shapely.geometry.shapeandshapely.is_valid. A2.xresult fromshapely.__version__is mandatory; the vectorised functions used here do not exist in 1.x. - A defined coordinate reference system (CRS). Pydantic validates coordinate structure, not projection. Geometry validity is evaluated in the XY plane, so confirm the collection’s CRS is documented — GeoJSON is EPSG:4326 by specification — before trusting validity results. See Coordinate Reference System Precision Standards.
- A written attribute contract listing each property, its type, and its permitted range or value set. The mapping in Mapping Attribute Constraints to GeoJSON Schemas is the direct input to the model below.
Step-by-Step Procedure
Step 1 — Model feature properties with typed, constrained fields
Start with the attributes. Pydantic enforces types and ranges declaratively through Field constraints and Enum classes.
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
class Zoning(str, Enum):
residential = "residential"
commercial = "commercial"
industrial = "industrial"
class ParcelProperties(BaseModel):
model_config = ConfigDict(extra="forbid") # reject undeclared attributes
parcel_id: str = Field(pattern=r"^P-\d{3,}$")
zoning: Zoning
area_m2: float = Field(gt=0)
year_assessed: int = Field(ge=1900, le=2100)
Verification: construct one clean and one broken property set and confirm the broken one raises.
from pydantic import ValidationError
ParcelProperties(parcel_id="P-001", zoning="residential", area_m2=1000.0, year_assessed=2024)
try:
ParcelProperties(parcel_id="bad", zoning="unknown", area_m2=-1, year_assessed=3000)
except ValidationError as exc:
print(len(exc.errors()), "violations") # 4
Step 2 — Add a Shapely-backed geometry validator
Attach geometry to the same model so one validation call covers the whole feature. The validator parses the GeoJSON geometry mapping and rejects anything Shapely reports as invalid or empty.
import shapely
from shapely.geometry import shape
from pydantic import field_validator
class ParcelFeature(ParcelProperties):
geometry: dict # a GeoJSON geometry mapping: {"type": ..., "coordinates": ...}
@field_validator("geometry")
@classmethod
def geometry_must_be_valid(cls, v: dict) -> dict:
try:
geom = shape(v)
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"unparseable geometry: {exc}")
if geom.is_empty:
raise ValueError("geometry is empty")
if not shapely.is_valid(geom):
raise ValueError(f"invalid geometry: {shapely.is_valid_reason(geom)}")
return v
Verification: pass a self-intersecting bowtie and confirm the reason string surfaces in the error.
bowtie = {"type": "Polygon",
"coordinates": [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]]}
try:
ParcelFeature(parcel_id="P-002", zoning="commercial", area_m2=10.0,
year_assessed=2024, geometry=bowtie)
except ValidationError as exc:
print(exc.errors()[0]["msg"]) # "Value error, invalid geometry: Self-intersection..."
Step 3 — Control coercion so silent drift fails loudly
Pydantic coerces where it safely can — a numeric string to a float, for instance. That convenience hides upstream export bugs. For validation you usually want strict numeric handling on the fields where a type change signals real corruption.
from pydantic import ConfigDict, Field
class StrictParcelProperties(BaseModel):
# strict=True on the whole model disables lax string->number coercion
model_config = ConfigDict(extra="forbid", strict=True)
parcel_id: str
area_m2: float = Field(gt=0)
Verification: confirm a stringified number is now rejected rather than silently coerced.
try:
StrictParcelProperties(parcel_id="P-004", area_m2="1000.0")
except ValidationError as exc:
print(exc.errors()[0]["type"]) # "float_type" — the string was refused
Keep strict scoped deliberately: enable it where a type change means corruption (measurements, identifiers) and leave lax coercion where upstream formatting is genuinely variable.
Step 4 — Validate a whole FeatureCollection with aggregated errors
Real input is a collection, not one feature. Iterate it, validate each feature, and collect failures into a per-feature report rather than stopping on the first bad record.
import json
from dataclasses import dataclass, field
from pydantic import ValidationError
@dataclass
class FeatureReport:
valid: list[ParcelFeature] = field(default_factory=list)
failures: list[dict] = field(default_factory=list)
def validate_feature_collection(fc: dict) -> FeatureReport:
report = FeatureReport()
for i, feature in enumerate(fc.get("features", [])):
props = feature.get("properties", {})
payload = {**props, "geometry": feature.get("geometry")}
try:
report.valid.append(ParcelFeature.model_validate(payload))
except ValidationError as exc:
report.failures.append({
"index": i,
"parcel_id": props.get("parcel_id", "unknown"),
"errors": [
{"field": ".".join(str(p) for p in e["loc"]), "msg": e["msg"]}
for e in exc.errors()
],
})
return report
Verification: run against a small collection and confirm every bad feature is reported with all of its violations at once.
fc = {
"type": "FeatureCollection",
"features": [
{"type": "Feature",
"properties": {"parcel_id": "P-001", "zoning": "residential",
"area_m2": 1000.0, "year_assessed": 2024},
"geometry": {"type": "Polygon",
"coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}},
{"type": "Feature",
"properties": {"parcel_id": "bad", "zoning": "unknown",
"area_m2": -5.0, "year_assessed": 2024},
"geometry": {"type": "Polygon",
"coordinates": [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]]}},
],
}
report = validate_feature_collection(fc)
print(len(report.valid), "valid;", len(report.failures), "failed")
print(json.dumps(report.failures, indent=2))
Interpreting Results
Each entry in report.failures carries an index, a parcel_id, and a list of {field, msg} pairs drawn straight from ValidationError.errors(). The field string is the dotted path Pydantic reports in loc — zoning, area_m2, or geometry — so a triager can see that feature 2 failed on three distinct constraints without re-running validation field by field. Because Pydantic collects every violation in one pass, the report is complete on the first run: there is no fix-one-rerun loop.
To feed these results into the rest of the pipeline, map each failure to the shared ValidationResult contract — one result per {index, field} pair, with rule_name set to schema::<field> and failure_reason set to the message. That is the same shape Categorizing and Prioritizing Spatial Errors consumes, so a Pydantic contract breach is scored alongside topology and attribute failures on one severity scale.
Gotchas & Edge Cases
shape() accepts structurally malformed coordinates that are still invalid geometries. A polygon ring with only three points parses without error but fails is_valid with "Too few points in geometry component". Always run shapely.is_valid after shape(); parsing success is not validity.
extra="forbid" is off by default. Without it, Pydantic silently ignores undeclared properties, so a misspelled attribute name (zonning) passes validation while the real field stays unset. Set extra="forbid" on every model that guards external data.
GeoJSON winding order is not enforced by is_valid. The GeoJSON specification recommends right-hand-rule winding, but Shapely’s is_valid follows the Open Geospatial Consortium (OGC) Simple Features model, which does not reject reversed exterior rings. If winding matters for a downstream renderer, add an explicit orientation check with shapely.geometry.polygon.orient; do not assume is_valid covers it.
A Pydantic Enum rejects valid values that differ only by case or whitespace. "Residential " with a trailing space fails against a residential enum member. Normalise strings in a mode="before" validator (v.strip().lower()) if the source data has inconsistent casing, rather than loosening the enum.
model_validate on a huge list is memory-bound. Building one model instance per feature holds every validated object in memory. For collections beyond a few hundred thousand features, stream the input and discard validated models you do not need, or move to a column-level approach.
When to Escalate
Pydantic is the right tool for record-level validation at a boundary. Move to a heavier method when:
- The collection exceeds the low hundreds of thousands of features. Per-record model instantiation dominates runtime. Switch to a column-level pandera schema or a Great Expectations suite that runs
shapely.is_validonce over the wholeGeoSeries, or to Integrating Great Expectations with GeoPandas when you also need shareable Data Docs. - You need cross-feature checks. Pydantic validates one feature in isolation and cannot express “no two parcels overlap” or “the road network is connected.” Those relational rules belong in the engine described in Building Rule Engines with GeoPandas.
- Repair, not just rejection, is required. Pydantic reports failures; it does not fix geometry. When invalid features must be salvaged rather than dropped, route them through the
make_validrepair patterns in Implementing Shapely Geometry Checks in Python.
Frequently Asked Questions
Why validate geometry inside a Pydantic validator instead of after loading?
Putting the geometry check inside a field_validator keeps the geometry constraint attached to the same model that owns the attributes, so a single ParcelFeature call returns every property and geometry violation together in one ValidationError. Validating geometry separately after loading splits the contract across two code paths, loses the per-field error location Pydantic provides, and makes it easy for one path to drift out of sync with the other.
How does Pydantic v2 aggregate multiple errors per feature?
Pydantic v2 does not stop at the first failing field. It runs all field validators and collects every violation into a single ValidationError, whose .errors() method returns a list of dictionaries each carrying loc (the field path), msg, and type. This lets you report that a feature failed on zoning, area_m2, and geometry simultaneously, which is essential when triaging a whole FeatureCollection rather than fixing one field at a time.
Should I use model_validate or construct for GeoJSON features?
Use model_validate (or calling the model directly) for any data from outside your process — it runs coercion and every validator. Reserve model_construct for trusted, already-validated data where you deliberately want to skip validation for speed. For GeoJSON arriving from files, APIs, or user uploads, always use model_validate so the geometry and attribute contracts actually execute.
Does Pydantic scale to large FeatureCollections?
Pydantic validates one model instance per feature, so per-record overhead is real. For collections up to the low hundreds of thousands of features it is fine, especially at an application programming interface or streaming boundary. Above that, switch geometry-and-attribute validation to a column-level pandera schema or a Great Expectations suite that runs shapely.is_valid once over the whole GeoSeries rather than per record.
Related:
- Declarative Validation Frameworks for Geospatial Data — how Pydantic, pandera, and Great Expectations fit together
- Integrating Great Expectations with GeoPandas — the batch-and-evidence counterpart to record-level Pydantic
- Mapping Attribute Constraints to GeoJSON Schemas — the attribute contract that seeds these models
Back to Declarative Validation Frameworks for Geospatial Data