Dataclasses and Pydantic v2

@dataclass for in-process types, Pydantic for boundary validation, and the frozen/kw_only options that matter.

๐Ÿงฑ Module 2 8 min read Not started

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

  1. Use @dataclass fluently with all common options.
  2. Use NamedTuple for tiny immutable records.
  3. Recognise attrs style and decide between it and dataclasses.
  4. Use Pydantic v2 for parsed/validated models.
  5. Pick the right tool per use case.

1. The decision tree

NeedTool
Quick record, mostly hashable, no parsingNamedTuple
Internal mutable record, no parsing@dataclass
External data needing validation/coercionPydantic
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

python
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__ for match statements.

What you do NOT get unless you ask:

  • __hash__: only auto-generated if eq=True and frozen=True. Otherwise __hash__ = None.
  • Ordering (<, etc.): set order=True.

field(...) for fine control

python
@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 like marshmallow).

__post_init__

For derived fields or validation:

python
@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.height

Decorator options

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

frozen=True, slots=True is the "value object" sweet spot: hashable, immutable, memory-efficient.

Inheritance

Dataclasses inherit cleanly:

python
@dataclass
class Animal:
    name: str

@dataclass
class Dog(Animal):
    breed: str

With 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

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

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

python
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 + validate

Why 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

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

Validators

Field-level:

python
class M(BaseModel):
    x: int
    @field_validator("x")
    @classmethod
    def even(cls, v):
        if v % 2: raise ValueError("must be even")
        return v

Model-level (after all fields validated):

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

Config

python
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

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

6. dataclass vs Pydantic โ€” when to use which

dataclassPydantic
Validation at constructionmanual in __post_init__built-in
Type coercionnoyes
JSON serialisemanualmodel_dump_json()
Speed of constructionfastfast (v2 Rust core)
Standard library?yesno
OpenAPI / JSON Schemanoyes

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)

python
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

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

  1. Convert your Money class from Lesson 2.1 to @dataclass(frozen=True, slots=True). Compare line count.
  2. Convert it to a NamedTuple. Compare API.
  3. Build a Pydantic Order(BaseModel) with id: UUID4, placed_at: datetime, items: list[OrderLine], total: Decimal. Add validation that total == sum(line.price * line.qty).
  4. Parse a JSON file of orders into list[Order]; print validation errors nicely.
  5. Build a Settings(BaseSettings) that reads APP_API_KEY from env and a .env file.
  6. Define a TypedDict for an API response; use it in a function signature; run mypy.
  7. Bonus: write a tiny mapper to_dataclass(req: PydanticModel) -> DataclassModel.

Common pitfalls

  1. Mutable default in a dataclass without default_factory โ†’ ValueError at class definition.
  2. Forgetting that @dataclass without frozen=True keeps __hash__ = None โ†’ unhashable.
  3. Using Pydantic on hot paths inside the app where validation overhead matters.
  4. Mixing Pydantic and dataclasses awkwardly. Pick one per layer.
  5. Forgetting model_config = ConfigDict(extra="forbid"); silently accepting typos in field names.
  6. Pydantic v1 syntax (@validator, Config) sneaking into v2 code. v2 uses @field_validator and model_config.

Self-check

  1. When use NamedTuple over dataclass?
  2. What does frozen=True do to a dataclass?
  3. Difference between Pydantic and dataclasses.
  4. How does pydantic-settings find env vars?
  5. What is TypedDict for?

References

Sign in to save your progress and earn badges.