Pydantic v1 vs v2 for expense policy schema validation

Choosing between Pydantic v1 and v2 for the schema that guards every incoming expense record is a decision about validation throughput, migration risk, and how strictly a mis-typed amount is rejected before it reaches policy code — not a cosmetic upgrade. This comparison sits under the Expense Category Taxonomies topic in the broader Core Policy Architecture & Taxonomy Design control plane, where the schema is the contract that turns a raw OCR row into a machine-validated record. It weighs the two versions on the axes that matter for an audit pipeline: the Rust-backed performance gap, the changed validator and configuration APIs, the migration gotchas that silently alter coercion, and when v1’s frozen stability is still the correct call.

The stakes are concrete: a schema that coerces "12.3O" (letter O) into a number, or that flips strict behavior between versions, changes which expenses are rejected at the door — and every such change must be reproducible under audit.

Pydantic v1 and v2 validation paths compared side by side Two vertical lanes share one raw expense record at the top. The left lane, labelled Pydantic v1, runs a pure-Python validation core with class-based Config, validator and root_validator decorators, and lax coercion by default, ending in a moderate-throughput validated record. The right lane, labelled Pydantic v2, routes through a Rust pydantic-core engine with model_config, field_validator and model_validator decorators, and optional strict mode, ending in a high-throughput validated record roughly five to fifty times faster. A shared decision note at the bottom states that v2 is the default for new pipelines while v1 remains appropriate for frozen dependency trees. One record, two validation cores Same schema contract; different engine, decorator API, and default strictness. Raw expense record amount · date · merchant · currency Pydantic v1 pure-Python core · class Config validator · root_validator · lax coercion Pydantic v2 Rust pydantic-core · model_config field_validator · model_validator · strict opt-in Validated record moderate throughput · stable API Validated record high throughput · ~5–50× faster core Default to v2 for new pipelines · keep v1 only for frozen dependency trees.

Why Standard Approaches Fail

Teams treat the v1-to-v2 move as a drop-in bump and get burned in three specific ways, each of which quietly changes which expense records pass validation.

  • Silent coercion changes. v1 and v2 disagree on edge-case coercion — v2 is stricter about int-from-float and rejects some string-to-number conversions v1 accepted. A pipeline that leaned on v1’s leniency to parse a stray OCR artifact starts rejecting rows after the upgrade, or worse, a strict default assumed in one version is absent in the other, so a corrupted amount slips through.
  • Decorator API drift. @validator and @root_validator still import in v2 as deprecated shims but change signature semantics; a pre=True validator or a mutable-default field behaves differently, so a copy-pasted v1 validator compiles and runs while enforcing the wrong rule. This is the dangerous failure: no error, wrong behavior.
  • Config surface rename. The inner class Config is replaced by model_config, and keys like allow_mutation, orm_mode, and schema_extra are renamed or removed. A schema that still ships class Config under v2 has its settings silently ignored, so a model you believe is frozen is mutable, and an audit assumption breaks without a traceback.

The remedy is to treat the choice as a deliberate comparison against your pipeline’s throughput, strictness, and dependency constraints, then migrate with an explicit compatibility pass rather than a version bump.

Architecture & Algorithm

The side-by-side below is the decision surface. For an expense schema, the axes that move the choice are validation throughput at ingestion volume, how strict coercion is by default, the ergonomics of the validator API, and the cost of the migration itself.

Dimension Pydantic v1 Pydantic v2
Validation core Pure Python Rust (pydantic-core); roughly 5–50× faster on nested models
Config mechanism inner class Config model_config = ConfigDict(...)
Field validator @validator("f") @field_validator("f")
Cross-field validator @root_validator @model_validator(mode="after")
Strict typing opt-in per field, limited first-class strict=True per field or model-wide
Serialization .dict(), .json() .model_dump(), .model_dump_json()
Immutability flag allow_mutation = False frozen=True
ORM loading orm_mode = True from_attributes=True
Custom types __get_validators__ __get_pydantic_core_schema__ / Annotated
Python support 3.6+ (legacy runtimes) 3.8+
Best fit frozen dependency trees, legacy runtimes, minimal-change maintenance new pipelines, high-volume ingestion, strict audit coercion

For an audit pipeline the throughput column is usually decisive: validation runs on every row of a million-row monthly batch, and the Rust core moves it from a measurable bottleneck to negligible. The v2 schema below is the recommended target — strict where money and dates demand it, with a model_validator enforcing a cross-field rule that a single field validator cannot express.

from __future__ import annotations

from datetime import date
from decimal import Decimal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


class ExpensePolicyRecord(BaseModel):
    """v2 expense schema: strict money, coerced identifiers, frozen once built."""

    model_config = ConfigDict(
        strict=False,          # coerce identifiers, but override per money field
        frozen=True,           # immutable after construction (audit safety)
        extra="forbid",        # reject unknown OCR columns instead of ignoring them
    )

    expense_id: str
    merchant_raw: str
    amount: Decimal = Field(strict=True, gt=Decimal("0"), max_digits=12, decimal_places=2)
    currency_iso: str = Field(min_length=3, max_length=3)
    transaction_date: date
    ocr_confidence: float = Field(ge=0.0, le=1.0)

    @field_validator("currency_iso")
    @classmethod
    def upper_currency(cls, v: str) -> str:
        """Normalize the ISO 4217 code so USD and usd validate identically."""
        return v.upper()

    @model_validator(mode="after")
    def reject_future_dated(self) -> "ExpensePolicyRecord":
        """Cross-field rule: a settled expense cannot post in the future."""
        if self.transaction_date > date.today():
            raise ValueError("transaction_date is in the future")
        return self


def validate_row(raw: dict) -> ExpensePolicyRecord:
    """Construct and validate one record; raises ValidationError on any breach."""
    return ExpensePolicyRecord.model_validate(raw)

The strict=True on amount is the load-bearing line: it forces the letter-O-for-zero and stringly-typed float artifacts of OCR to raise rather than coerce, while the model-level strict=False still lets a clean "0.97" confidence string coerce to float. That mixed strictness is precisely what v2 expresses cleanly and v1 cannot.

Step-by-Step Integration

  1. Pin the version explicitly and read it at runtime. Record pydantic.VERSION in the audit metadata for every batch so a decision can be tied to the validator that produced it; a schema’s behavior is a function of the library major version.

  2. Inventory the v1 surface before touching code. Grep for class Config, @validator, @root_validator, .dict(, and orm_mode; each is a rename or a semantics change, and the deprecated shims will hide the ones you miss.

  3. Convert config and validators together. Move class Config to model_config = ConfigDict(...), @validator to @field_validator with an explicit @classmethod, and @root_validator to @model_validator(mode="after"), matching the target schema shape used in Expense Category Taxonomies.

  4. Verify coercion parity on real edge cases before enabling the new schema in enforcement:

    import pytest
    from pydantic import ValidationError
    
    def test_strict_amount_rejects_ocr_artifact():
        with pytest.raises(ValidationError):
            validate_row({
                "expense_id": "E1", "merchant_raw": "HILTON",
                "amount": "12.3O",  # letter O — must NOT coerce to a number
                "currency_iso": "usd", "transaction_date": "2026-07-01",
                "ocr_confidence": 0.97,
            })
    
    def test_clean_row_validates_and_uppercases_currency():
        rec = validate_row({
            "expense_id": "E2", "merchant_raw": "MARRIOTT",
            "amount": "412.50", "currency_iso": "usd",
            "transaction_date": "2026-07-01", "ocr_confidence": 0.97,
        })
        assert rec.currency_iso == "USD"
        assert str(rec.amount) == "412.50"
  5. Run both schemas in shadow for one batch. Diff the accept/reject set of the v1 and v2 schemas over a recent day of traffic; any row whose verdict flips is a coercion difference to review before cutover, not after.

  6. Snapshot the schema version alongside the taxonomy version. Tie the Pydantic major version into the same replay metadata used by Policy Versioning & Rollout, so an auditor can reconstruct both which taxonomy and which validator judged a historical expense.

Edge Cases & Gotchas

Edge condition What breaks Mitigation
class Config left under v2 Settings silently ignored; a “frozen” model is mutable Convert to model_config = ConfigDict(...); grep to confirm none remain
Deprecated @validator shim Compiles and runs but with changed pre/always semantics Migrate to @field_validator with explicit mode and @classmethod
int-from-float coercion v2 rejects 1.0int that v1 accepted, flipping verdicts Decide per field; use strict=True on money, leave identifiers lax
.dict() in downstream code Method still exists but is deprecated; nested output shape shifts Replace with .model_dump(); pin the mode (json vs python)
Mutable default field v2 handling of mutable defaults differs; shared-state bugs appear Use Field(default_factory=...) rather than a bare mutable literal
Mixed v1/v2 in one process Two majors cannot share a model registry; imports collide Never straddle; migrate the whole schema module in one change
float money under either version Both will happily hold a lossy float amount Use Decimal with max_digits/decimal_places, never float, for amounts

FAQ

Is Pydantic v2 always the right choice for a new expense pipeline?

For a new build, yes in almost every case: the Rust pydantic-core engine validates roughly 5 to 50 times faster on nested models, which matters when validation runs on every row of a monthly batch, and first-class strict mode gives you the per-field coercion control an audit schema needs. The main reasons to start on v1 are a frozen dependency tree that pins an incompatible transitive requirement, or a runtime older than Python 3.8. Absent those constraints, default to v2.

What is the single most dangerous v1-to-v2 migration gotcha?

The deprecated validator shims. @validator and @root_validator still import under v2, so a copied v1 validator compiles and runs without a traceback while enforcing subtly different pre/always semantics. That silent behavior change is far more dangerous than a hard import error, because it passes tests that only check the happy path. Migrate every decorator explicitly to @field_validator or @model_validator rather than leaning on the shim.

How do strict mode and coercion actually differ between the versions?

v1 coercion is lax by default with only limited per-field strictness, so it will turn many stringly-typed inputs into numbers. v2 makes strict a first-class setting at both the field and model level, and its default coercion is tighter — it rejects some int-from-float and string-to-number conversions v1 allowed. For expense data that means you can force an amount field to reject OCR artifacts with strict=True while still coercing a clean confidence string, a mix v1 cannot express cleanly.

Can I run Pydantic v1 and v2 models in the same service?

Not in the same process against one model registry — the two majors cannot share model definitions, and mixing them causes import and metaclass collisions. The pydantic.v1 compatibility namespace under a v2 install helps you migrate incrementally, but treat any single schema module as all-v1 or all-v2 and convert it in one change. Straddling versions inside one validation path is a source of hard-to-trace verdict differences.

Why keep money as Decimal rather than trusting Pydantic to validate a float?

Neither version protects you from floating-point representation error — both will hold a float amount that has already lost precision before Pydantic ever sees it. Declaring the field as Decimal with max_digits and decimal_places makes the schema reject an over-precise or malformed amount and preserves exact cent values through reconciliation. The validator enforces the shape; using Decimal is what keeps the value itself correct.