The Quiet Terminal

← Practical Tips

dataclass: the end of twelve-line __init__ methods

2026-05-29 · 4 min read

Most of the classes I write aren't clever. They hold data: an invoice, a config, a row pulled out of a CSV. For years each one cost me the same toll — an __init__ that assigns every parameter to self, plus a __repr__ I either skipped or wrote badly. The repr was the part I always skipped, and the part I always missed: seeing <__main__.Invoice object at 0x10ad3f490> in a print statement is how a five-minute debugging session becomes an hour.

Before: the toll

class Invoice:
    def __init__(self, number, client, total, items=None):
        self.number = number
        self.client = client
        self.total = total
        self.items = items if items is not None else []

    def __repr__(self):
        return f"Invoice(number={self.number!r}, client={self.client!r}, total={self.total!r})"

Twelve lines to say "an invoice has four fields." And it's still incomplete: two invoices with identical data aren't equal, because there's no __eq__.

After: the same class in five lines

from dataclasses import dataclass, field

@dataclass
class Invoice:
    number: str
    client: str
    total: float = 0.0
    items: list = field(default_factory=list)

The decorator generates the __init__, a readable __repr__Invoice(number='A-1042', client='Otto Ltd', total=120.0, items=[]) — and field-by-field __eq__, which makes comparing an imported record against an expected one a one-liner. The type hints aren't enforced at runtime, but they document the fields and they're what the decorator reads to build everything; leave them off and it stops working. Plain defaults like total: float = 0.0 work as usual — the factory is only needed for mutable values. One ordering rule carries over from plain functions: fields without defaults can't follow fields that have them.

The one gotcha: mutable defaults

You cannot write items: list = []. Python refuses:

ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

That's a good error — it names the fix in the message. The reason underneath: a plain [] would be created once and shared by every invoice, so appending to one invoice's items would silently append to all of them. default_factory=list calls list() fresh for each new instance, so every invoice gets its own empty list.

Nothing stops a dataclass from having behavior, either. You add methods like in any other class — this one grows a computed property without disturbing the generated parts:

@dataclass
class Invoice:
    number: str
    client: str
    total: float = 0.0

    @property
    def is_paid(self):
        return self.total == 0.0

One line on frozen=True

@dataclass(frozen=True) makes instances unchangeable: assigning to a field raises FrozenInstanceError, and the objects become hashable, so they can sit in sets and dict keys. Worth it when a value should be set once and trusted afterward.

My rule now: every data-shaped class starts as a dataclass, and only grows into a hand-written class when it earns real behavior beyond holding fields. The twelve-line __init__ has left my life, and I don't miss it.