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.


Pydantic feature validation flow A single GeoJSON Feature enters and splits into two paths: its properties dictionary is checked by typed Pydantic fields with constraints and enums, and its geometry is parsed by shapely.shape then checked with is_valid inside a field_validator. Both paths merge at the model. If all pass, a validated ParcelFeature is emitted; if any fail, Pydantic aggregates every violation into one ValidationError report. GeoJSON Feature Typed fields constraints + enums field_validator shape() + is_valid Model ParcelFeature Validated model all fields pass ValidationError aggregated pass any fail

Prerequisites

  • Python 3.10+ — required for the X | Y union syntax used in field annotations.
  • Pydantic 2.6+ — every example uses the v2 API. Confirm with python -c "import pydantic; print(pydantic.VERSION)"; a leading 1. means the wrong major version is installed and @field_validator and model_config will not exist.
  • Shapely 2.0+ — the geometry validator calls shapely.geometry.shape and shapely.is_valid. A 2.x result from shapely.__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 loczoning, 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:

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:

Back to Declarative Validation Frameworks for Geospatial Data