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=Trueon@dataclass, theX | Noneunion syntax, andzoneinfoin the standard library. - Pydantic v2 (
pydantic>=2.6) — the boundary model relies on v2field_validatorsemantics andConfigDict; a v1 model will not parse the code below. decimal.Decimalfor every money and metered quantity — rate values and tier breakpoints are financial, sofloatis 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.DictReaderrow — 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.
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"toDecimal("0.1120"), but it will also coerce a barefloatlike0.112, silently importing binary rounding error into a rate. Feed rate values as strings (or Decimals) and keepfloatout 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 renamesrate_per_kwhtoenergy_rateor adds arider_codecolumn 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:
@validatorbecomes@field_validatorwith an explicitmode,class Configbecomesmodel_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
Related Topics
- Schema Validation & Data Quality Checks — the parent guide covering validation strategy across the ingestion tier.
- Meter Data Ingestion & Validation Pipelines — the end-to-end subsystem this modeling choice serves.
- Validating CSV Meter Exports with Pydantic Models — the same boundary pattern applied to a single meter reading.
- Modeling Inclining Block Rates with Decimal — the rating hot path that reads the frozen schedule this page produces.
- Async Batch Processing for High-Volume Reads — fanning the immutable record across workers at billing-run scale.
Up: Schema Validation & Data Quality Checks · Meter Data Ingestion & Validation Pipelines