Quick Tip
Reach for @dataclass(slots=True, frozen=True) with a __post_init__ check and you get a small, immutable, validated value object in about five lines.
The Problem
The problem with plain dicts
Passing structured data around as plain dicts feels quick at first, but it rots fast. There is no autocomplete, no type checking, typos in keys fail silently at runtime, and nothing stops one part of the code from mutating a dict another part is still using. You end up writing defensive data.get("x") calls everywhere and guessing at the shape.
Real-world impact
At scale this shows up as two concrete costs. Memory: millions of small objects each carrying a per-instance __dict__ add up quickly. Correctness: a mutable default (the classic def __init__(self, items=[])) silently shares one list across every instance, and immutable-looking data gets mutated in place. Both are the kind of bug that only bites in production.
The Solution
The fix
Dataclasses give you a real typed object with generated __init__, __repr__, and __eq__ for free. Two flags make them production-ready: slots=True (Python 3.10+) drops the per-instance dict for less memory and faster access, and frozen=True makes instances immutable and hashable. Validation and derived fields go in __post_init__, and mutable defaults use field(default_factory=...).
TL;DR
slots=Truefor memory and speed;frozen=Truefor safe, hashable value objects.- Validate and compute derived fields in
__post_init__. - Never use a mutable default directly; use
field(default_factory=list).
The difference slots=True makes to each instance:
Script Implementation
Setup
Start with a plain, typed dataclass. Even without any flags this beats a dict: you get types, a real constructor, and a readable repr.
from dataclasses import dataclass, field
@dataclassclass Point: x: float y: floatSlots and frozen
Add slots=True for memory and frozen=True for immutability. A frozen instance is hashable, so it works as a dict key or set member.
@dataclass(slots=True, frozen=True)class Point: x: float y: float
p = Point(1.0, 2.0)seen = {p} # hashable: works in a set# p.x = 5.0 # raises FrozenInstanceErrorValidation with __post_init__
__post_init__ runs right after the generated __init__, so it is the place for validation and derived fields. On a frozen instance you set derived fields with object.__setattr__.
@dataclass(slots=True, frozen=True)class Temperature: celsius: float fahrenheit: float = field(init=False)
def __post_init__(self) -> None: if self.celsius < -273.15: raise ValueError("below absolute zero") # frozen instances block normal assignment, so go around it: object.__setattr__(self, "fahrenheit", self.celsius * 9 / 5 + 32)Mutable defaults and nesting
Never write tags: list = [] on a dataclass; every instance would share the same list. Use field(default_factory=...), and nest dataclasses freely.
@dataclass(slots=True)class Order: id: str items: list[str] = field(default_factory=list) # fresh list per instance shipping: "Point | None" = None # optional nested fieldUsage and Benefits
Why this helps
You get objects that are small, safe, and self-checking, with almost no boilerplate. slots=True cuts memory on large collections of objects, frozen=True makes it impossible to accidentally mutate shared state, and __post_init__ means an invalid object can never be constructed in the first place.
Using it
>>> Temperature(25.0)Temperature(celsius=25.0, fahrenheit=77.0)>>> Temperature(-300)ValueError: below absolute zeroCommunity Discussion
Your turn
How do you decide between a dataclass, Pydantic, and a TypedDict? I lean on the rule below, but I am curious where other people draw the line.
Alternative approaches
The three tools solve different problems. Pydantic parses and validates untrusted input at runtime, which makes it the right choice at an API boundary. TypedDict adds static typing over a plain dict with zero runtime cost, good for JSON you just pass through. Dataclasses sit in between: real objects with behavior, light validation, and memory control, for your own trusted internal models.










