Pydantic v1 vs v2 for Compliance Payload Validation: A Decision Guide
Problem statement
When a payload carries a regulated pesticide application rate into your system, the validation layer is not a convenience — it is the boundary that decides whether a non-conforming record ever reaches an EPA or USDA submission. Pydantic is the de facto validation library for that boundary in Python AgTech stacks, and it exists in two materially different major versions. Pydantic v1 is the mature, pure-Python line many projects still run. Pydantic v2 rebuilt the core in Rust (pydantic-core), changed the validator API, and tightened type coercion. For a compliance payload the differences are not cosmetic: they change how a Decimal rate is coerced, whether a string sneaks in where a number belongs, how you express a custom agronomic rule, and how fast you can validate a season’s worth of records.
This guide compares the two across the dimensions that decide correctness and cost for regulated payloads — the validator API, Rust-core performance, Decimal and constraint handling, strict mode, and migration effort — then recommends v2 with concrete migration guidance. The schema these models enforce is designed in field schema design, and the rules they encode trace back to the statutory mappings in EPA/USDA rule mapping. Here the concern is the validator itself: which Pydantic major version gives a compliance boundary the strictness, the numeric fidelity, and the throughput it needs.
The failure that motivates the choice is quiet. Under Pydantic v1’s lax coercion a rate of "2.5" as a string, or a float 2.5 that cannot be represented exactly, passes validation and is stored as an approximate value — then a downstream total drifts by a cent per acre and an auditor finds it. The right version, configured strictly, makes that impossible to commit.
Comparison matrix
The table summarizes; the sections after it explain each row. Every dimension is a place where v1 and v2 behave differently enough to change compliance outcomes or migration cost.
| Dimension | Pydantic v1 | Pydantic v2 |
|---|---|---|
| Validator API | @validator / @root_validator, pre/post flags |
@field_validator / @model_validator, explicit mode="before"/"after" |
| Core & performance | Pure Python; slowest on large batches | Rust pydantic-core; roughly 5–20x faster validation |
| Decimal handling | Coerces loosely; float→Decimal can carry binary error | Preserves Decimal; strict mode rejects float coercion outright |
| Constraints | conint, condecimal, constraints on the type |
Field(..., ge=, le=, max_digits=, decimal_places=) + Annotated |
| Strict mode | Not first-class; opt-in per field, patchy | First-class strict=True at field, model, or call site |
| Serialization | .dict() / .json(); Decimal→float in JSON by default |
.model_dump() / .model_dump_json(); Decimal fidelity controllable |
| Migration cost | — | Real: renamed methods, Config→model_config, bump-pydantic assists |
Validator API
v1’s custom logic hangs off @validator("field") and @root_validator, with pre=True deciding whether your code runs before or after coercion. v2 renames and clarifies this: @field_validator("field", mode="before") for a single field and @model_validator(mode="after") for cross-field rules, with the timing explicit in the signature rather than a boolean flag. For a compliance model the mode="after" model validator is where cross-field agronomic rules live — for example, that a rate does not exceed a product’s label maximum — and v2’s explicit ordering removes a common v1 footgun where a pre/post mixup let an unvalidated value into a rule.
Core and performance
v2 moved the validation engine into a compiled Rust core, pydantic-core, which routinely validates 5–20x faster than v1’s pure-Python path. For a compliance workload that revalidates a season of application records or a nightly batch of telemetry, that is the difference between a job that finishes in seconds and one that dominates the pipeline. The speedup is free once you are on v2 — it requires no code changes beyond the migration itself.
Decimal and constraint handling
This is the dimension that most directly touches correctness. A regulated rate must be an exact Decimal, never a float, because binary floating point cannot represent values like 2.5 lb/ac precisely and the error compounds across a total. v1 coerces loosely: a float assigned to a Decimal field is accepted and carries its binary imprecision into storage. v2 in strict mode rejects that coercion outright, forcing the payload to present a string or an already-exact Decimal. Constraints move too — v1’s condecimal(max_digits=..., decimal_places=...) becomes Field(max_digits=..., decimal_places=..., ge=...), expressible with Annotated so the constraint travels with the type. Together these let you say, precisely, that a rate has at most four total digits and two decimal places and is non-negative, and have the validator enforce it in the Rust core.
Strict mode
v1 treats strictness as a patchy per-field opt-in; v2 makes strict=True first-class at the field, the model, or the individual validation call. For a compliance boundary this matters because lax coercion is exactly the wrong default: you want a string "true" for a boolean flag, or a float for a Decimal rate, to be rejected, not silently converted. v2 lets you flip the whole model into strict mode and then relax individual fields where coercion is genuinely safe, rather than v1’s inverse and error-prone posture.
Serialization
v1’s .dict() and .json() become v2’s .model_dump() and .model_dump_json(), and the JSON path changed how Decimal is emitted. For a submission package you must control that: v2 lets you keep Decimal fidelity (as a string) rather than letting it degrade to a float on the way out, which protects the exact value all the way to the EPA/USDA rule mapping layer that consumes it.
Migration cost
The one column where v1 “wins” is that it needs no migration. Moving to v2 is real work: renamed decorators and methods, class Config becoming model_config, changed default coercion that may surface latent bugs, and validators that must be rewritten with explicit modes. The official bump-pydantic codemod automates much of the mechanical rename, but the semantic changes — especially anywhere strictness now rejects input v1 accepted — need review.
Recommendation: migrate to Pydantic v2
For a compliance payload validator, Pydantic v2 is the right choice, and the reasons compound: strict mode makes lax coercion opt-in rather than the default, Decimal fidelity is preserved instead of silently degraded, and the Rust core makes revalidating large regulated batches cheap. The migration cost is real but bounded and one-time.
- Choose v2 for any new compliance validator. There is no reason to start a regulated payload boundary on v1 today; begin on v2 with
strict=TrueandDecimalrate fields. - Migrate an existing v1 validator on a schedule, not in a panic. Run
bump-pydanticfor the mechanical rename, then audit every field where v2 strictness now rejects input v1 accepted — those are latent data-quality bugs surfacing, not regressions to suppress. - Stay on v1 only if the cost is genuinely prohibitive right now — a large surface with no test coverage — and even then, wrap the boundary so the eventual migration is localized. Treat v1 as the legacy state to leave, not a destination.
- During migration, run both in parallel on a sample. Validate the same payloads through v1 and v2 and diff the results; every disagreement is either a coercion v2 correctly tightened or a rule you must port faithfully.
Parameter reference table
The values below govern the v2 model in the next section. Defaults assume regulated rate payloads bound for an EPA/USDA submission.
| Parameter | Type | Recommended value | Effect on behavior |
|---|---|---|---|
strict (model_config) |
bool |
True |
Rejects lax coercion model-wide; a string "2.5" for a float or a float for a Decimal fails instead of converting. The core defense for a compliance boundary. |
max_digits (rate Field) |
int |
6 |
Total significant digits allowed on a Decimal rate. Caps precision to what the label and units support; excess digits are a validation error. |
decimal_places (rate Field) |
int |
2 |
Fractional digits allowed on a rate. Enforces the reporting granularity regulators expect and blocks spurious precision. |
ge (rate Field) |
Decimal |
Decimal("0") |
Lower bound; a negative application rate is physically impossible and must be rejected at the boundary. |
validate_assignment |
bool |
True |
Re-runs validation when a field is mutated after construction, so a later edit cannot bypass the rules the payload passed on ingest. |
extra (model_config) |
str |
"forbid" |
Rejects unexpected keys instead of ignoring them, so a renamed or injected field in a regulated payload is caught, not silently dropped. |
Runnable implementation
The module below is the recommended v2 compliance validator: strict mode, an exact-Decimal rate with digit and range constraints, a field_validator for units, and a model_validator for a cross-field label rule. It targets Python 3.10+, is fully typed, and uses Pydantic v2 (pydantic>=2).
from __future__ import annotations
import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationError,
field_validator,
model_validator,
)
logger = logging.getLogger("compliance.validate")
# Label maxima keyed by EPA registration number; a rate may not exceed its cap.
LABEL_MAX_RATE: dict[str, Decimal] = {
"524-579": Decimal("2.50"), # lb a.i. / acre
"100-1442": Decimal("1.00"),
}
ALLOWED_UNITS = {"lb_ai_per_acre", "pt_per_acre", "fl_oz_per_acre"}
# An exact, bounded Decimal rate: non-negative, <=6 total digits, <=2 decimals.
RateDecimal = Annotated[Decimal, Field(ge=Decimal("0"), max_digits=6, decimal_places=2)]
class ApplicationPayload(BaseModel):
# strict rejects float->Decimal and str->number coercion; extra=forbid blocks
# unexpected keys; validate_assignment re-checks any post-construction edit.
model_config = ConfigDict(strict=True, extra="forbid", validate_assignment=True)
field_id: str
epa_reg_no: str
rate: RateDecimal
unit: str
applied_at: datetime # must be tz-aware UTC
@field_validator("unit")
@classmethod
def _known_unit(cls, v: str) -> str:
if v not in ALLOWED_UNITS: # runs in "after" mode by default
raise ValueError(f"unit {v!r} not in allowed set")
return v
@field_validator("applied_at")
@classmethod
def _tz_aware_utc(cls, v: datetime) -> datetime:
if v.tzinfo is None: # a naive timestamp is ambiguous in a legal record
raise ValueError("applied_at must be timezone-aware")
return v.astimezone(timezone.utc) # normalize to UTC for the ledger
@model_validator(mode="after")
def _within_label_max(self) -> ApplicationPayload:
cap = LABEL_MAX_RATE.get(self.epa_reg_no)
if cap is None:
raise ValueError(f"no label maximum registered for {self.epa_reg_no}")
if self.rate > cap: # cross-field rule: rate vs product label
raise ValueError(f"rate {self.rate} exceeds label max {cap} for {self.epa_reg_no}")
return self
def validate_payload(raw: dict[str, object]) -> ApplicationPayload | None:
"""Validate one regulated payload; log and reject anything non-conforming."""
try:
payload = ApplicationPayload.model_validate(raw) # v2 entry point
except ValidationError as exc:
# exc.errors() is structured; log it verbatim for the audit trail.
logger.error(
'{"event":"payload_rejected","field_id":%s,"errors":%d}',
_q(raw.get("field_id")), len(exc.errors()),
)
return None
logger.info(
'{"event":"payload_validated","field_id":"%s","epa_reg_no":"%s","rate":"%s"}',
payload.field_id, payload.epa_reg_no, payload.rate,
)
return payload
def _q(v: object) -> str:
"""Quote a possibly-missing field_id for the JSON log line."""
return f'"{v}"' if isinstance(v, str) else "null"
The rate field is an exact Decimal — never a float — with digit and range constraints enforced in the Rust core, and strict mode means a float or numeric string is rejected rather than coerced. The model_validator(mode="after") is where the cross-field label-maximum rule lives, the kind of agronomic constraint that only makes sense once every individual field has already validated.
Log patterns and observable signals
Every validation emits structured JSON so a rejected regulated payload leaves an auditable trace.
Payload accepted, exact rate preserved as a decimal string:
{"event": "payload_validated", "field_id": "PFD1", "epa_reg_no": "524-579", "rate": "2.50"}
Payload rejected — the structured error count points at how many rules failed:
{"event": "payload_rejected", "field_id": "PFD1", "errors": 1}
When triaging, treat every payload_rejected as a record that never entered the compliance store. A rise in rejections right after a v1→v2 migration is usually strict mode correctly refusing coercions v1 had been silently accepting — inspect the errors() detail before relaxing anything, because loosening the model to make the count fall reintroduces the exact data-quality gap the migration closed. Correlate persistent rejections for one epa_reg_no against EPA/USDA rule mapping to confirm the label maximum is current.
Troubleshooting
- A float rate that worked under v1 now fails under v2. Root cause: v2 strict mode rejects float→
Decimalcoercion because floats cannot represent decimal rates exactly. Remediation: send the rate as a string ("2.5"), not a float; this is the migration surfacing a latent imprecision, not a regression to suppress. @validatorraisesAttributeErrorafter upgrading. Root cause: the v1 decorator was removed in v2. Remediation: rewrite as@field_validator(or@model_validatorfor cross-field rules) with an explicitmode; runbump-pydanticto automate the mechanical rename first.Configclass silently ignored. Root cause: v2 replaced the innerclass Configwithmodel_config = ConfigDict(...). Remediation: move settings likestrict,extra, andvalidate_assignmentintoConfigDict; the old class no longer takes effect.- A post-construction edit bypasses validation. Root cause:
validate_assignmentdefaults to off, so mutating a field aftermodel_validateskips the rules. Remediation: setvalidate_assignment=Trueinmodel_configso any later edit is re-checked before it can reach the compliance store. - Extra keys in a payload are silently dropped. Root cause: the model does not forbid unexpected fields, so an injected or renamed key passes unnoticed. Remediation: set
extra="forbid"so a malformed regulated payload is rejected rather than partially accepted.
Frequently asked questions
Should a new compliance validator use v1 or v2? v2 — strict mode, preserved Decimal fidelity, and a fast Rust core all favor it, and there is no reason to start a regulated boundary on v1.
Why must a rate be a Decimal not a float? Floats cannot represent decimal rates exactly, so the imprecision compounds into a drifting legal figure; v2 strict mode rejects the float coercion outright.
How much work is the migration? Bounded — bump-pydantic automates the renames; the manual part is auditing fields where v2’s tighter coercion correctly rejects input v1 had accepted.
Related
- Field schema design — the normalized schema these validated payloads are written into.
- EPA/USDA rule mapping — the statutory rules the model validators encode, and the layer that consumes the validated
Decimalrate.
Up: Field Schema Design · Agricultural Automation System Architecture & Compliance