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, Configmodel_config, bump-pydantic assists
Pydantic v1 versus v2 compliance-validation comparison matrix A seven-row matrix comparing Pydantic v1 and v2 on validator API, core and performance, Decimal handling, constraints, strict mode, serialization, and migration cost for validating regulated AgTech payloads. Dimension Pydantic v1 pure Python Pydantic v2 Rust pydantic-core Validator API Core & speed Decimal handling Constraints Strict mode Serialization Migration cost @validator / @root_validator pre & post flags pure Python slowest on batches loose coercion float carries binary error conint / condecimal constraint on the type patchy, per-field not first-class .dict() / .json() Decimal to float in JSON baseline (legacy) @field_validator / @model_validator explicit mode before / after Rust pydantic-core ~5 to 20x faster preserves Decimal strict rejects float Field(ge=, max_digits=) Annotated constraints strict=True first-class field / model / call .model_dump() / json Decimal fidelity kept real: bump-pydantic helps

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=True and Decimal rate fields.
  • Migrate an existing v1 validator on a schedule, not in a panic. Run bump-pydantic for 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).

python
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:

json
{"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:

json
{"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→Decimal coercion 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.
  • @validator raises AttributeError after upgrading. Root cause: the v1 decorator was removed in v2. Remediation: rewrite as @field_validator (or @model_validator for cross-field rules) with an explicit mode; run bump-pydantic to automate the mechanical rename first.
  • Config class silently ignored. Root cause: v2 replaced the inner class Config with model_config = ConfigDict(...). Remediation: move settings like strict, extra, and validate_assignment into ConfigDict; the old class no longer takes effect.
  • A post-construction edit bypasses validation. Root cause: validate_assignment defaults to off, so mutating a field after model_validate skips the rules. Remediation: set validate_assignment=True in model_config so 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.

  • 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 Decimal rate.

Up: Field Schema Design · Agricultural Automation System Architecture & Compliance