Dataclasses

Stop writing __init__, __repr__ and __eq__ by hand, and know which of the four record types to reach for.

16of 24
≈ 12Min read
5Parts

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.

by hand
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)
the same thing
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.

py
@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.

py
@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.

py
@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.

NeedUse
A quick pair or triple, thrown away immediatelytuple
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 coercedPydantic, or your own __post_init__
A fixed set of named constantsenum.Enum

Practice