The same class, twice
The decorator reads the annotations and writes __init__, __repr__ and __eq__ for you. The annotations are what make it work — a field without one is not a field, it is a class attribute, and that silent difference is the mistake to watch for.
class Door:
def __init__(self, width, height, finish="oak"):
self.width = width
self.height = height
self.finish = finish
def __repr__(self):
return f"Door(width={self.width!r}, height={self.height!r}, finish={self.finish!r})"
def __eq__(self, other):
if not isinstance(other, Door):
return NotImplemented
return (self.width, self.height, self.finish) == (other.width, other.height, other.finish)
from dataclasses import dataclass
@dataclass
class Door:
width: int
height: int
finish: str = "oak"
The options that matter
frozen=True is the one to reach for by default: assignment raises, __hash__ is generated, and the object can go in a set or be a dict key. Most records never need to change after they are made, and saying so removes a whole category of bug.
@dataclass(frozen=True) # immutable, and therefore hashable
@dataclass(order=True) # adds <, <=, >, >= comparing the fields in order
@dataclass(slots=True) # no per-instance dict: smaller and faster
@dataclass(kw_only=True) # every field must be passed by name
Mutable defaults, again
Dataclasses are the one place where Python catches the mutable-default bug instead of letting it through: a mutable default raises immediately. default_factory is the fix, and it is called once per instance.
@dataclass
class Order:
items: list = [] # ValueError, at class definition time
from dataclasses import field
@dataclass
class Order:
items: list = field(default_factory=list) # a new list per order
created: str = field(default_factory=now)
secret: str = field(repr=False, default="") # kept out of __repr__
total: float = field(init=False, default=0) # not an __init__ argument
__post_init__, and the helpers
replace is how you "modify" a frozen object: it builds a new one from the old values with your changes on top. That is the whole immutable-update pattern, in one call.
@dataclass
class Rect:
width: int
height: int
area: float = field(init=False)
def __post_init__(self):
if self.width <= 0:
raise ValueError("width must be positive")
self.area = self.width * self.height
from dataclasses import asdict, astuple, replace, fields
asdict(rect) # {'width': 2, 'height': 3, 'area': 6} — recursive
astuple(rect)
replace(rect, width=10) # a NEW object with one field changed
[f.name for f in fields(rect)]
Which record type
A NamedTuple is immutable, hashable and unpacks like a tuple, so it slots into code that expects one. A dataclass is a class: it can have methods, inheritance and mutability. Neither validates types at runtime — the annotations are documentation, and the typing lesson explains what actually checks them.
| Need | Use |
|---|---|
| A quick pair or triple, thrown away immediately | tuple |
| A fixed record that should behave like a tuple (indexable, unpackable) | typing.NamedTuple |
| A record with defaults, methods, validation or mutability | @dataclass |
| Data arriving from outside that must be validated and coerced | Pydantic, or your own __post_init__ |
| A fixed set of named constants | enum.Enum |