Migrating AFC Event Models to Pydantic v2

This page walks the concrete migration of an automated fare collection (AFC) event contract from Pydantic v1 to v2: swapping @validator for @field_validator, replacing the inner Config class with model_config, moving .dict() calls to .model_dump(), and rewriting root validators as @model_validator. The one non-negotiable constraint throughout is that Decimal fare fields stay bit-for-bit exact across the change, because a validator rewrite that quietly re-coerces cents is a revenue bug, not a refactor. This is maintenance work inside Schema Validation Pipelines, the validation stage of the Fare Data Ingestion & GTFS-RT Sync pipeline.

The payoff is real: Pydantic v2’s Rust-backed pydantic-core validates roughly 5–20x faster than the pure-Python v1 engine, which matters when a mid-size agency pushes three million taps a day through model_validate. But the API surface shifted enough that a mechanical find-and-replace silently changes serialization semantics. This guide is the map from each v1 concept to its v2 replacement, with before/after snippets you can diff against your own models.

Migration map

Every deprecated v1 construct has a direct v2 replacement; the diagram below pairs each one so you can audit a model class top to bottom. Read it left (v1) to right (v2): the accounting-critical rows — the Decimal fare validator and the .dict() serializer — are the two most likely to change behavior if migrated carelessly.

Concept map from Pydantic v1 to v2 constructs Five Pydantic v1 constructs on the left map to their v2 replacements on the right. The v1 @validator decorator maps to @field_validator with a classmethod. The inner Config class maps to model_config assigned a ConfigDict. The @root_validator maps to @model_validator with mode before or after. The .dict() serializer maps to .model_dump(). The Decimal fare field with a validator maps to a field_validator that still returns Decimal unchanged, the accounting-critical row highlighted so it stays exact through the migration. Pydantic v1 Pydantic v2 @validator("field") class Config: @root_validator .dict() Decimal fare + @validator accounting-critical @field_validator + classmethod model_config = ConfigDict(...) @model_validator(mode=...) .model_dump() field_validator returns Decimal unchanged → still exact

Step 1 — The v1 model as it stands

Here is the AFC event model as many teams first wrote it under Pydantic 1.x. It uses a bare @validator, an inner Config for extra and allow_mutation, a @root_validator for the cross-field tap-in/tap-out rule, and a Decimal field for the collected fare so that money never touches binary float.

from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional

from pydantic import BaseModel, validator, root_validator  # Pydantic v1 API


class TapDirection(str, Enum):
    ENTRY = "entry"
    EXIT = "exit"


class AFCEventV1(BaseModel):
    event_id: str
    device_id: str
    direction: TapDirection
    tap_timestamp: datetime
    fare_charged: Decimal          # exact money, never float
    tap_out_timestamp: Optional[datetime] = None

    class Config:
        extra = "forbid"
        allow_mutation = False
        json_encoders = {Decimal: str}   # serialize money losslessly

    @validator("tap_timestamp", "tap_out_timestamp")
    def enforce_utc(cls, v: Optional[datetime]) -> Optional[datetime]:
        if v is None:
            return v
        if v.tzinfo is None:
            raise ValueError("timestamp must be timezone-aware")
        return v.astimezone(timezone.utc)

    @validator("fare_charged")
    def non_negative_fare(cls, v: Decimal) -> Decimal:
        if v < 0:
            raise ValueError("fare_charged cannot be negative")
        return v

    @root_validator
    def check_exit_after_entry(cls, values: dict) -> dict:
        tin = values.get("tap_timestamp")
        tout = values.get("tap_out_timestamp")
        if tout is not None and tin is not None and tout < tin:
            raise ValueError("tap-out precedes tap-in")
        return values

Note the four v1 idioms that all change in v2: the plain @validator (no @classmethod), the class Config, the @root_validator that reads a mutable values dict, and the json_encoders hook that keeps Decimal from being coerced to a float during serialization.

Step 2 — The v2 rewrite, field by field

The same contract in Pydantic v2. Every validator now carries an explicit @classmethod, Config becomes a model_config = ConfigDict(...) assignment, allow_mutation = False becomes frozen=True, and the root validator becomes a @model_validator(mode="after") that operates on the constructed instance (self) rather than a loose dict. Crucially, the fare_charged field stays typed Decimal and its validator returns the value untouched, so the migration cannot round or re-coerce a single cent.

from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional

from pydantic import BaseModel, ConfigDict, field_validator, model_validator  # v2 API


class TapDirection(str, Enum):
    ENTRY = "entry"
    EXIT = "exit"


class AFCEventV2(BaseModel):
    # class Config -> model_config; allow_mutation=False -> frozen=True.
    # Decimal is serialized losslessly by default in v2, so json_encoders is gone.
    model_config = ConfigDict(extra="forbid", frozen=True)

    event_id: str
    device_id: str
    direction: TapDirection
    tap_timestamp: datetime
    fare_charged: Decimal          # unchanged: still exact money
    tap_out_timestamp: Optional[datetime] = None

    @field_validator("tap_timestamp", "tap_out_timestamp")
    @classmethod
    def enforce_utc(cls, v: Optional[datetime]) -> Optional[datetime]:
        if v is None:
            return v
        if v.tzinfo is None:
            raise ValueError("timestamp must be timezone-aware")
        return v.astimezone(timezone.utc)

    @field_validator("fare_charged")
    @classmethod
    def non_negative_fare(cls, v: Decimal) -> Decimal:
        # Return the Decimal untouched. Do NOT cast through float here.
        if v < 0:
            raise ValueError("fare_charged cannot be negative")
        return v

    @model_validator(mode="after")
    def check_exit_after_entry(self) -> "AFCEventV2":
        # mode="after" runs on the built model: read attributes, not a dict.
        if self.tap_out_timestamp is not None and self.tap_out_timestamp < self.tap_timestamp:
            raise ValueError("tap-out precedes tap-in")
        return self

Two subtleties bite teams during this rewrite. First, forgetting @classmethod under @field_validator raises at class-definition time in v2, so it fails loudly — good. Second, a v1 @root_validator reading values.get(...) tolerated missing keys silently; the v2 mode="after" validator sees a fully constructed model, so a field that failed its own validator short-circuits before the model validator ever runs. That ordering change is usually what you want, but verify it against any cross-field rule that previously ran on partial data.

Step 3 — Serialization: .dict() becomes .model_dump()

The other breaking change with money implications is serialization. In v1 you called .dict() and .json(); in v2 they are .model_dump() and .model_dump_json(), and the way Decimal renders differs. Keep the fare exact by dumping in a mode that never touches float.

from decimal import Decimal

event = AFCEventV2(
    event_id="evt-9001",
    device_id="VAL-3B",
    direction="entry",
    tap_timestamp="2026-07-03T08:14:05Z",
    fare_charged=Decimal("2.75"),
)

# v1: event.dict()  ->  {"fare_charged": Decimal("2.75"), ...}
py_obj = event.model_dump()                 # fare_charged stays a Decimal
assert py_obj["fare_charged"] == Decimal("2.75")

# v1: event.json()  ->  '{"fare_charged": "2.75", ...}'  (via json_encoders)
# v2: Decimal serializes to a JSON string by default — no encoder hook needed.
as_json = event.model_dump_json()
assert '"fare_charged":"2.75"' in as_json

# The dangerous line to grep your codebase for:
#   event.model_dump(mode="json")  serializes Decimal to a STRING (safe),
# but if any downstream code does float(py_obj["fare_charged"]) the exactness is lost.

model_dump(mode="python") (the default) preserves the Decimal object; mode="json" renders it as a JSON string, not a float, so no precision is lost on the wire. The failure mode to hunt for is not in Pydantic at all — it is downstream code that used to receive a stringified decimal from json_encoders and now needs updating, or code that casts the dumped value through float().

Validation & Test Cases

The migration is correct only if a good payload round-trips identically and a bad payload is still rejected. The block below pins both: exact Decimal preservation through a full dump/reload cycle, and a rejected tap-out-before-tap-in.

from decimal import Decimal
from pydantic import ValidationError

# 1. Round-trip: a clean event survives model_dump_json -> model_validate_json exact.
original = AFCEventV2(
    event_id="evt-1",
    device_id="VAL-1",
    direction="exit",
    tap_timestamp="2026-07-03T08:00:00Z",
    tap_out_timestamp="2026-07-03T08:25:00Z",
    fare_charged=Decimal("3.10"),
)
restored = AFCEventV2.model_validate_json(original.model_dump_json())
assert restored.fare_charged == Decimal("3.10")     # exact, no float drift
assert restored == original                          # frozen models compare by value

# 2. Bad payload still rejected: tap-out precedes tap-in (model_validator fires).
try:
    AFCEventV2(
        event_id="evt-2",
        device_id="VAL-1",
        direction="exit",
        tap_timestamp="2026-07-03T09:00:00Z",
        tap_out_timestamp="2026-07-03T08:30:00Z",
        fare_charged=Decimal("3.10"),
    )
    raise AssertionError("should have rejected reversed tap pair")
except ValidationError as exc:
    assert "tap-out precedes tap-in" in str(exc)

# 3. extra="forbid" still rejects an undocumented firmware key.
try:
    AFCEventV2.model_validate({
        "event_id": "evt-3", "device_id": "VAL-1", "direction": "entry",
        "tap_timestamp": "2026-07-03T09:00:00Z", "fare_charged": "1.00",
        "battery_pct": 88,
    })
    raise AssertionError("should have rejected extra key")
except ValidationError as exc:
    assert "battery_pct" in str(exc)

The first case is the one that protects settlement: Decimal("3.10") written to JSON as the string "3.10" and parsed back is still Decimal("3.10") — no intermediate float ever exists, so nothing rounds. The second and third confirm the rewritten @model_validator and the migrated extra="forbid" config still reject exactly what the v1 model rejected.

Edge Cases & Gotchas

  • allow_reuse is gone. v1 needed @validator(..., allow_reuse=True) to share a function across models; v2 has no such flag — just reference the function.
  • each_item=True has no direct equivalent. A v1 validator that ran per-list-item becomes a field_validator typed on the element or an Annotated type with a validator; do not assume the old signature still iterates.
  • pre=True maps to mode="before". Any v1 validator that pre-processed raw input (stringy cents, sentinels) must become @field_validator(..., mode="before") or it will run after coercion and see the wrong type.
  • .copy() is now .model_copy(), and .parse_obj() is .model_validate(). Grep for every legacy method name.
  • Decimal constraints moved. v1 Field(..., max_digits=..., decimal_places=...) still works, but pair it with the condecimal-to-Annotated change if you used the constrained-type shorthand.

Integration Note

This migration keeps the entry contract for Schema Validation Pipelines on a supported, faster foundation without changing what reaches the ledger. The model here is the same shape catalogued in Implementing Pydantic Models for AFC Event Streams, which already uses the v2 ConfigDict and field_validator idioms — this page is the bridge that gets a legacy v1 codebase to that target. Migrate the models first, run both engines side by side against a day of production payloads, and only then delete the v1 imports. Refer to the Pydantic documentation for the full deprecation list.

FAQ

Will migrating to Pydantic v2 change how my Decimal fares serialize?
Only if you relied on a v1 json_encoders hook and never updated downstream consumers. In v2, Decimal serializes to a JSON string by default (for example "2.75"), which is lossless — no float ever exists. The real risk is downstream code that used to parse that string and now needs the same handling, or any line that casts the dumped value through float(). Keep the field typed Decimal and use model_dump_json(), and the money stays exact.
Do I have to add @classmethod to every field_validator?
Yes. In Pydantic v2 the @field_validator decorator expects a classmethod, and omitting @classmethod raises at class-definition time rather than silently misbehaving — so the failure is loud and immediate. This is stricter than v1's bare @validator, which is a good thing: the error points you straight at the line to fix.
How does @model_validator(mode="after") differ from the old @root_validator?
The v1 @root_validator received a mutable values dict that could hold partially validated or missing fields, so cross-field checks had to defensively .get() each key. The v2 mode="after" validator runs on a fully constructed model instance, so you read self.tap_timestamp directly, and any field that failed its own validator short-circuits before the model validator runs. Verify any rule that previously depended on seeing partial data.
Is the performance gain worth the migration effort?
For high-volume AFC ingestion, yes. Pydantic v2's pydantic-core is written in Rust and validates roughly 5–20x faster than v1's pure-Python engine, which is meaningful when you validate millions of taps a day through model_validate in a hot loop. The migration is also forced eventually — v1 is end-of-life — so doing it deliberately, with the Decimal round-trip tests above as a safety net, beats an emergency upgrade later.

Part of Schema Validation Pipelines, within Fare Data Ingestion & GTFS-RT Sync.