Pydantic v2 vs Dataclasses for Rate Schema Validation

Every municipal billing pipeline has to answer one modeling question early and live with it forever: how do you represent a rate schedule or a meter record in Python? The two obvious tools pull in opposite directions. Pydantic v2 excels at turning untrusted, string-shaped input into typed, bounds-checked objects; stdlib dataclasses produce lightweight, immutable records that construct in a fraction of the time. Choosing one for everything is the mistake — a Pydantic model in a tight rating loop wastes cycles re-validating data that is already clean, while a bare dataclass at the ingestion edge waves through the malformed tier boundary that later mis-prices a bill. This page sits under Schema Validation & Data Quality Checks, part of the broader Meter Data Ingestion & Validation Pipelines subsystem, and lays out a clear rule: validate at the boundary with Pydantic v2, then cross once into a frozen slots dataclass for the hot path.

Minimal Prerequisites

  • Python 3.11+ — for slots=True on @dataclass, the X | None union syntax, and zoneinfo in the standard library.
  • Pydantic v2 (pydantic>=2.6) — the boundary model relies on v2 field_validator semantics and ConfigDict; a v1 model will not parse the code below.
  • decimal.Decimal for every money and metered quantity — rate values and tier breakpoints are financial, so float is never acceptable; a binary rounding error on a tier boundary changes which marginal rate applies.
  • Data assumptions: a rate schedule arrives from an untrusted source — a PUC filing export, a vendor JSON payload, or a csv.DictReader row — as strings and nested dicts. Downstream, a rating engine reads the schedule millions of times per billing run and must never mutate it.

Annotated Implementation

The two libraries are not competitors so much as neighbors on either side of a line. Pydantic owns the untrusted side; the dataclass owns the trusted interior. The comparison below is the reasoning that assigns each tool its side.

Dimension Pydantic v2 Frozen slots dataclass
Validation Declarative constraints (ge, le, pattern), custom field_validator / model_validator, precise ValidationError per field None built in; you write and call your own checks, and nothing forces you to
Coercion Parses strings to Decimal, date, Enum automatically at construction No coercion; whatever you pass is stored verbatim, wrong type included
Speed (construct) Fast for what it does, but validation is real work on every instance Near-free; effectively a typed tuple, ideal in a hot loop
Immutability model_config = ConfigDict(frozen=True) available but heavier per instance frozen=True, slots=True gives cheap immutability and lower memory
Serialization model_dump() / model_dump_json() round-trip built in dataclasses.asdict() only; Decimal/date handling is your problem
When to use The untrusted boundary: ingesting a filing, a vendor feed, or an API body The validated interior: rating, proration, and reconciliation hot paths

The pattern this site uses everywhere follows directly. First, a Pydantic model that treats the rate schedule as hostile input and refuses to let a malformed tier through:

from __future__ import annotations

from datetime import date
from decimal import Decimal

from pydantic import BaseModel, ConfigDict, Field, field_validator


class RateTierModel(BaseModel):
    # Reject unknown keys so a renamed filing column fails loudly, not silently.
    model_config = ConfigDict(extra="forbid")

    upto_kwh: Decimal | None = Field(default=None, ge=0)  # None = final unbounded tier
    rate_per_kwh: Decimal = Field(ge=0, le=Decimal("10.0"))


class RateScheduleModel(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")

    schedule_code: str = Field(pattern=r"^[A-Z]{2,4}-\d{3}$")  # e.g. RES-001
    customer_class: str
    effective_on: date                                          # "2026-07-01" -> date
    fixed_charge: Decimal = Field(ge=0, le=Decimal("999.99"))
    tiers: tuple[RateTierModel, ...] = Field(min_length=1)

    @field_validator("tiers")
    @classmethod
    def breakpoints_ascend(cls, v: tuple[RateTierModel, ...]) -> tuple[RateTierModel, ...]:
        # An inclining-block schedule is incoherent if its breakpoints are out of order.
        bounds = [t.upto_kwh for t in v if t.upto_kwh is not None]
        if bounds != sorted(bounds):
            raise ValueError("tier breakpoints must be strictly ascending")
        return v

Then the interior record — the same rate schedule, but as a frozen, slotted dataclass the rating engine can construct and read millions of times without a validation tax:

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class RateTier:
    upto_kwh: Decimal | None
    rate_per_kwh: Decimal


@dataclass(frozen=True, slots=True)
class RateSchedule:
    schedule_code: str
    customer_class: str
    effective_on: date
    fixed_charge: Decimal
    tiers: tuple[RateTier, ...]


def to_hot_path(model: RateScheduleModel) -> RateSchedule:
    """Cross the boundary exactly once: validated model -> immutable interior record."""
    return RateSchedule(
        schedule_code=model.schedule_code,
        customer_class=model.customer_class,
        effective_on=model.effective_on,
        fixed_charge=model.fixed_charge,
        tiers=tuple(RateTier(t.upto_kwh, t.rate_per_kwh) for t in model.tiers),
    )

The whole design is in to_hot_path: validation happens once, at the edge, where the data is still untrusted; everything past that call handles a RateSchedule that is guaranteed well-formed, frozen against accidental mutation, and cheap to pass around. The same discipline applies to a single meter reading — a Pydantic model like the one in validating vendor CSV meter exports at ingestion, a frozen dataclass in the inclining block-rate calculation that reads it.

Pydantic boundary feeding a frozen dataclass interior Untrusted input — a PUC filing export, vendor JSON, or a CSV row arriving as strings and nested dicts — enters a Pydantic v2 boundary model. There the schedule is coerced to Decimal, date, and Enum types, checked against field constraints such as ge and le bounds and the schedule-code pattern, refused any unknown key by extra equals forbid, and validated for ascending tier breakpoints. The validated model is then converted exactly once into a frozen, slotted RateSchedule dataclass that forms the interior. The rating, proration, and reconciliation hot path reads that immutable record millions of times per billing run with no per-instance validation cost. Untrusted input filing export · vendor JSON strings + nested dicts Pydantic v2 boundary coerce → Decimal · date · Enum ge / le bounds · code pattern extra="forbid" tier breakpoints ascend validate the hostile edge Frozen slots dataclass frozen=True, slots=True rating hot path · millions/run read-only interior parse cross once

Edge Cases and Billing Gotchas

The boundary/interior split resolves most trouble, but a few cases decide whether the split is drawn correctly.

  • Decimal coercion from strings. Pydantic v2 coerces "0.1120" to Decimal("0.1120"), but it will also coerce a bare float like 0.112, silently importing binary rounding error into a rate. Feed rate values as strings (or Decimals) and keep float out of the payload entirely, so the value the ledger prices with is exactly the value the PUC filed.
  • extra="forbid" versus silent drift. A filing that renames rate_per_kwh to energy_rate or adds a rider_code column will pass a permissive model and quietly drop the field. extra="forbid" turns that drift into a loud rejection on the first row instead of a whole cycle billed against a missing rate — the same defense the upstream schema validation and data quality checks apply to meter feeds.
  • Performance on millions of rows. Re-instantiating a Pydantic model per meter inside the rating loop is the classic scaling trap. Validate the schedule once, convert to the frozen dataclass, and let async batch processing for high-volume reads fan the immutable record out across workers; a slotted dataclass is roughly a typed tuple and constructs an order of magnitude faster while using less memory per instance.
  • v1 to v2 migration pitfalls. Porting from Pydantic v1 is not a rename: @validator becomes @field_validator with an explicit mode, class Config becomes model_config = ConfigDict(...), .dict() becomes .model_dump(), and v2 is stricter about int/str coercion. A model that “still parses” after a mechanical port may have quietly lost a coercion rule, so re-run the full fixture rather than trusting that it imports.

Verification Snippet

Pin the boundary behavior with a fixture: good input coerces and crosses, an unknown key is rejected, and the interior refuses mutation.

from dataclasses import FrozenInstanceError

import pytest


def test_boundary_coerces_then_interior_is_frozen() -> None:
    raw = {
        "schedule_code": "RES-001",
        "customer_class": "residential",
        "effective_on": "2026-07-01",          # str -> date
        "fixed_charge": "12.50",               # str -> Decimal, exact
        "tiers": [
            {"upto_kwh": "500", "rate_per_kwh": "0.1120"},
            {"upto_kwh": None, "rate_per_kwh": "0.1440"},
        ],
    }
    model = RateScheduleModel(**raw)
    assert model.fixed_charge == Decimal("12.50")     # no float drift
    assert isinstance(model.effective_on, date)

    sched = to_hot_path(model)
    assert isinstance(sched.tiers[0].rate_per_kwh, Decimal)
    with pytest.raises(FrozenInstanceError):
        sched.fixed_charge = Decimal("0")             # interior is immutable


def test_unknown_key_and_bad_order_are_rejected() -> None:
    with pytest.raises(Exception):  # pydantic.ValidationError: extra="forbid"
        RateScheduleModel(
            schedule_code="RES-001", customer_class="residential",
            effective_on="2026-07-01", fixed_charge="12.50",
            tiers=[{"upto_kwh": "500", "rate_per_kwh": "0.11"}],
            rider="oops",
        )
    with pytest.raises(Exception):  # descending breakpoints
        RateScheduleModel(
            schedule_code="RES-001", customer_class="residential",
            effective_on="2026-07-01", fixed_charge="12.50",
            tiers=[
                {"upto_kwh": "800", "rate_per_kwh": "0.11"},
                {"upto_kwh": "500", "rate_per_kwh": "0.14"},
            ],
        )

Run this against a replay of real filings, not synthetic data — that is what surfaces the vendor who ships a float rate or the analyst who transposes two tier breakpoints, before either reaches a bill.

FAQ

Can't I just use Pydantic everywhere and skip the dataclass?
You can, and for a small pipeline it is fine. The reason to convert is the rating loop: re-validating an already-validated schedule millions of times per run is wasted work, and a frozen slots dataclass constructs far faster with a smaller memory footprint. Validate once at the edge where the data is untrusted, then carry the cheap immutable record through the hot path.
Do frozen dataclasses give me any validation at all?
Only type hints, which are not enforced at runtime — a dataclass will happily store a str where you annotated Decimal. That is precisely why it belongs on the trusted side of the boundary. The Pydantic model in front of it guarantees the values are already coerced and bounded, so the dataclass never has to defend itself against bad input.
Why is Decimal coercion a hazard rather than a convenience?
Pydantic v2 will coerce a JSON float to Decimal, but a float like 0.112 is already an inexact binary value, so the Decimal you get inherits that error. Rate values must arrive as strings or Decimals so the coerced value is exactly what the PUC filed. Keep float out of the payload and the tier boundary prices deterministically.
What breaks most often when migrating a model from Pydantic v1 to v2?
The silent losses. @validator becomes @field_validator with an explicit mode, class Config becomes model_config = ConfigDict(...), and .dict() becomes .model_dump(). A mechanical port can import cleanly while having dropped a coercion or validation rule, so the only safe check is re-running the full fixture suite against the ported model rather than trusting that it still parses.
Where exactly should the boundary sit in an ingestion pipeline?
At the first point untrusted bytes become objects — the filing parser, the vendor feed reader, the API body handler. Everything upstream of that call is strings and dicts; everything downstream is a frozen, validated record. Drawing the line there means only one layer ever has to think about malformed input, and the rating, proration, and reconciliation code never does.

Up: Schema Validation & Data Quality Checks · Meter Data Ingestion & Validation Pipelines