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