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.
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
strictdefault assumed in one version is absent in the other, so a corrupted amount slips through. - Decorator API drift.
@validatorand@root_validatorstill import in v2 as deprecated shims but change signature semantics; apre=Truevalidator 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 Configis replaced bymodel_config, and keys likeallow_mutation,orm_mode, andschema_extraare renamed or removed. A schema that still shipsclass Configunder 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
-
Pin the version explicitly and read it at runtime. Record
pydantic.VERSIONin 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. -
Inventory the v1 surface before touching code. Grep for
class Config,@validator,@root_validator,.dict(, andorm_mode; each is a rename or a semantics change, and the deprecated shims will hide the ones you miss. -
Convert config and validators together. Move
class Configtomodel_config = ConfigDict(...),@validatorto@field_validatorwith an explicit@classmethod, and@root_validatorto@model_validator(mode="after"), matching the target schema shape used in Expense Category Taxonomies. -
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" -
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.
-
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.0→int 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.
Related
- Expense Category Taxonomies — the parent guide whose record schema this validator choice underpins
- Policy Versioning & Rollout — where the schema version is snapshotted for replayable audits
- Core Policy Architecture & Taxonomy Design — the control plane this schema contract sits inside