Dataclasses and Pydantic v2
@dataclass for in-process types, Pydantic for boundary validation, and the frozen/kw_only options that matter.
Why this matters
90% of your "classes" are records: bundles of named fields with comparison, repr, and sometimes validation. Writing those by hand is repetitive and error-prone. Python gives you four good options โ dataclass (stdlib), NamedTuple (stdlib, immutable), attrs (third-party, predecessor to dataclasses), and Pydantic v2 (third-party, validation + parsing). Knowing when to use which is a senior-level skill.
Learning objectives
- Use
@dataclassfluently with all common options. - Use
NamedTuplefor tiny immutable records. - Recognise
attrsstyle and decide between it and dataclasses. - Use Pydantic v2 for parsed/validated models.
- Pick the right tool per use case.
1. The decision tree
| Need | Tool |
|---|---|
| Quick record, mostly hashable, no parsing | NamedTuple |
| Internal mutable record, no parsing | @dataclass |
| External data needing validation/coercion | Pydantic |
| Frozen value object, fast, slots | @dataclass(frozen=True, slots=True) |
| Library you don't want to leak Pydantic into | @dataclass or attrs |
2. @dataclass โ the workhorse
from dataclasses import dataclass, field
@dataclass
class User:
name: str
age: int
tags: list[str] = field(default_factory=list)
is_admin: bool = False
u = User("Ada", 30, ["dev"], True)
u # User(name='Ada', age=30, tags=['dev'], is_admin=True)
u == User("Ada", 30, ["dev"], True) # True (auto __eq__)What you get for free:
__init__matching the fields.__repr__showing field values.__eq__comparing field-wise.__match_args__formatchstatements.
What you do NOT get unless you ask:
__hash__: only auto-generated ifeq=Trueandfrozen=True. Otherwise__hash__ = None.- Ordering (
<, etc.): setorder=True.
field(...) for fine control
@dataclass
class Doc:
body: str
tags: list[str] = field(default_factory=list) # mutable default
_index: dict = field(default_factory=dict, repr=False, compare=False)Options:
default_factory: callable producing a default โ required for mutable defaults.repr=False: hide from__repr__.compare=False: exclude from__eq__/ ordering.init=False: not a__init__arg (set in__post_init__).metadata={...}: arbitrary annotations (used by libraries likemarshmallow).
__post_init__
For derived fields or validation:
@dataclass
class Rect:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
if self.width <= 0 or self.height <= 0:
raise ValueError("non-positive dimensions")
self.area = self.width * self.heightDecorator options
@dataclass(
frozen=True, # immutable; __setattr__ raises FrozenInstanceError
slots=True, # adds __slots__ (3.10+)
kw_only=True, # all fields keyword-only in __init__ (3.10+)
order=True, # adds __lt__, __le__, __gt__, __ge__
eq=True, # default; __eq__ on by default
repr=True,
init=True,
)
class Money:
amount: int
currency: strfrozen=True, slots=True is the "value object" sweet spot: hashable, immutable, memory-efficient.
Inheritance
Dataclasses inherit cleanly:
@dataclass
class Animal:
name: str
@dataclass
class Dog(Animal):
breed: strWith defaults, all fields after the first defaulted one must also have defaults โ both in the same class and across the hierarchy. kw_only=True (3.10+) sidesteps the rule.
3. NamedTuple โ minimal immutable record
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
z: float = 0.0
p = Point(1, 2)
p.x, p[0] # both work
p._asdict() # {'x': 1, 'y': 2, 'z': 0.0}
p._replace(x=99) # Point(x=99, y=2, z=0.0)NamedTuple:
- Immutable (it IS a tuple).
- Hashable.
- Comparable.
- Tuple-compatible (works with
*unpacking, indexing,len). - Tiny memory footprint.
Use for: tiny, simple records that flow through code; coordinates, currency pairs, hashable keys.
Limitations: can't have methods that mutate state (it's immutable); inheritance is fiddly.
4. attrs โ the predecessor / parallel track
attrs (pip install attrs) inspired dataclasses and remains popular for advanced use:
import attrs
@attrs.define
class User:
name: str
age: int = attrs.field(default=18, validator=attrs.validators.ge(0))Advantages over dataclasses:
- Better validators built in.
- Slots by default (
@define). evolve()for "copy with changes" (like_replace).- Converters for input normalisation.
- Faster.
If you're already on attrs, stay. For new code, @dataclass(slots=True) covers 90% of cases without an extra dependency.
5. Pydantic v2 โ when you need validation / parsing
pydantic (uv add pydantic) is the standard for external data: API request bodies, config files, LLM outputs, parsed JSON.
from pydantic import BaseModel, Field, EmailStr, field_validator
class User(BaseModel):
name: str = Field(min_length=1, max_length=50)
age: int = Field(ge=0, lt=150)
email: EmailStr
tags: list[str] = []
@field_validator("name")
@classmethod
def title_case(cls, v: str) -> str:
return v.title()
u = User(name="ada lovelace", age=30, email="ada@example.com")
u.name # "Ada Lovelace" (validator ran)
u.model_dump() # {'name': 'Ada Lovelace', 'age': 30, ...}
u.model_dump_json()
User.model_validate_json(raw) # parse + validateWhy Pydantic
- Coercion: strings to ints, ISO date strings to
datetime, etc. - Validation: types, ranges, custom checks, with great error messages.
- JSON Schema: auto-derived (used by FastAPI for OpenAPI docs).
- Serialisation:
model_dump()/model_dump_json(). - v2 speed: 10-50ร faster than v1 (Rust core via
pydantic-core).
Built-in field types
from pydantic import BaseModel, Field, HttpUrl, EmailStr, SecretStr, UUID4, PositiveInt
from datetime import datetime, date
from decimal import Decimal
class Order(BaseModel):
id: UUID4
placed_at: datetime
delivery_date: date
total: Decimal
url: HttpUrl
customer_email: EmailStr
api_key: SecretStr # masked in repr
quantity: PositiveIntValidators
Field-level:
class M(BaseModel):
x: int
@field_validator("x")
@classmethod
def even(cls, v):
if v % 2: raise ValueError("must be even")
return vModel-level (after all fields validated):
from pydantic import model_validator
class M(BaseModel):
start: int
end: int
@model_validator(mode="after")
def order(self):
if self.end < self.start:
raise ValueError("end before start")
return selfConfig
from pydantic import ConfigDict
class M(BaseModel):
model_config = ConfigDict(
frozen=True, # immutable
extra="forbid", # error on unknown fields
str_strip_whitespace=True,
populate_by_name=True, # accept field name OR alias
)Settings โ config from env / files
from pydantic_settings import BaseSettings, SettingsConfigDict # uv add pydantic-settings
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
openai_api_key: str
debug: bool = False
cfg = Settings() # reads from env / .env automatically6. dataclass vs Pydantic โ when to use which
| dataclass | Pydantic | |
|---|---|---|
| Validation at construction | manual in __post_init__ | built-in |
| Type coercion | no | yes |
| JSON serialise | manual | model_dump_json() |
| Speed of construction | fast | fast (v2 Rust core) |
| Standard library? | yes | no |
| OpenAPI / JSON Schema | no | yes |
Rule of thumb:
- Pydantic at the boundary (HTTP, file, LLM output, env config).
- dataclass inside the application (domain models, internal records).
In a FastAPI app, the typical layering is: Pydantic at request/response โ mapped to dataclass for the domain โ mapped back to Pydantic for the response.
7. TypedDict โ type a dict shape (no class)
from typing import TypedDict, NotRequired
class UserDict(TypedDict):
name: str
age: int
email: NotRequired[str] # optional key (3.11+)
u: UserDict = {"name": "Ada", "age": 30}TypedDict is type-checking only โ no runtime enforcement. Useful for typing dicts that come from JSON/API. For runtime validation, switch to Pydantic.
8. Worked example: a tiny config + API model
from dataclasses import dataclass
from pydantic import BaseModel, Field
# External boundary โ Pydantic
class UserCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=50)
age: int = Field(ge=0, lt=150)
email: str
class UserResponse(BaseModel):
id: int
name: str
age: int
# Internal domain โ dataclass (fast, no pydantic on every hot path)
@dataclass(slots=True, frozen=True)
class User:
id: int
name: str
age: int
email: str
def create_user(req: UserCreateRequest) -> UserResponse:
user = User(id=next_id(), name=req.name, age=req.age, email=req.email)
save(user)
return UserResponse(id=user.id, name=user.name, age=user.age)This separation keeps domain types free of validation overhead while still validating at the boundary.
Hands-on lab (2 hours)
- Convert your
Moneyclass from Lesson 2.1 to@dataclass(frozen=True, slots=True). Compare line count. - Convert it to a
NamedTuple. Compare API. - Build a Pydantic
Order(BaseModel)withid: UUID4,placed_at: datetime,items: list[OrderLine],total: Decimal. Add validation thattotal == sum(line.price * line.qty). - Parse a JSON file of orders into
list[Order]; print validation errors nicely. - Build a
Settings(BaseSettings)that readsAPP_API_KEYfrom env and a.envfile. - Define a
TypedDictfor an API response; use it in a function signature; run mypy. - Bonus: write a tiny mapper
to_dataclass(req: PydanticModel) -> DataclassModel.
Common pitfalls
- Mutable default in a dataclass without
default_factoryโValueErrorat class definition. - Forgetting that
@dataclasswithoutfrozen=Truekeeps__hash__ = Noneโ unhashable. - Using Pydantic on hot paths inside the app where validation overhead matters.
- Mixing Pydantic and dataclasses awkwardly. Pick one per layer.
- Forgetting
model_config = ConfigDict(extra="forbid"); silently accepting typos in field names. - Pydantic v1 syntax (
@validator,Config) sneaking into v2 code. v2 uses@field_validatorandmodel_config.
Self-check
- When use
NamedTupleoverdataclass? - What does
frozen=Truedo to a dataclass? - Difference between Pydantic and dataclasses.
- How does
pydantic-settingsfind env vars? - What is
TypedDictfor?
References
- PEP 557 โ Data Classes.
- Pydantic v2 docs: https://docs.pydantic.dev/latest/.
attrsdocs: https://www.attrs.org/.- Fluent Python, Ramalho โ Chapter 5.
- Hynek Schlawack, "Three Ways to Improve Your
dataclass."
Sign in to save your progress and earn badges.