Java has records, Lombok's @Data, and an IDE that writes
equals/hashCode for you. Python's answer is a decorator that reads your type
annotations and generates the methods at class-creation time — not a template, not a base class
with magic, just ordinary functions written into the class. Everything it does you could do by hand, which
is exactly what makes it worth measuring.
Pick the number of fields and the options, and compare: on the left is what you type, on the right is the hand-written class that behaves identically. Both are real, complete Python — the line counts underneath are counted from the text, not estimated:
The ratio is not the whole story — and it moves in an interesting direction. Adding fields grows both
columns by the same three lines, so more fields actually make the saving proportionally smaller. What
multiplies it is the options: order=True costs one keyword on the left and four methods on the
right. And the kind of code matters more than the count. Nothing in the right-hand column is
interesting; all of it is what goes wrong when you add a seventh field, update __init__ and
__repr__, and forget __eq__. The decorator cannot forget, because it re-derives
everything from the annotations every time the class is defined.
Three of the options are worth knowing by name:
frozen=True makes assignment raise FrozenInstanceError and — because
the object can no longer change — lets Python generate a __hash__, so instances work as dict
keys and set members. A mutable dataclass sets __hash__ = None instead, which is why an
ordinary dataclass in a set raises TypeError: unhashable type.order=True generates the four comparisons by treating the fields as a tuple, in
declaration order. That means field order silently defines your sort key — put id first and
you have sorted by id, whatever you meant.slots=True (3.10+) swaps the per-instance __dict__ for a fixed slot
layout: less memory per instance and faster attribute access, at the cost of not being able to add
attributes at runtime.This is the same trap as a mutable default argument, in a new place, and it is the one thing about
dataclasses you must know before you use them. A default value is evaluated once, when the class is
created — so tags: list = [] would give every instance the same list. Watch three separate
objects each append one tag:
Python actually protects you here: @dataclass raises ValueError: mutable default
at class-definition time if it sees a list, dict or set literal as a
default — one of the few places the language refuses to let you make the mistake. The left-hand behaviour
above is what you would get if you wrote the class by hand, and it is exactly why
field(default_factory=list) exists: the factory is called once per instance, so each
object gets its own.
from dataclasses import dataclass, field
@dataclass(frozen=True, order=True)
class Point:
x: float
y: float
label: str = "origin" # immutable default: fine
tags: list = field(default_factory=list) # mutable: needs a factory
Two more things you get for free once a class is a dataclass:
dataclasses.replace(p, x=3) returns a copy with one field changed — the standard way to "modify"
a frozen instance — and asdict(p) gives you a nested dict, which is how these become JSON. For
validation and parsing on top of the same idea, Phase 6 uses
pydantic, whose models are dataclasses
that also check types at runtime.
__init__ is built with exec and carries proper
defaults and __qualname__; the line counts compare like for like in intent, not in bytes ·
order=True compares fields as a tuple, so it only means what you want if declaration order
happens to be your sort order · eq=True with frozen=False sets
__hash__ = None, which is a deliberate safety feature and a common surprise · a dataclass gives
you no runtime type checking whatsoever — the annotations are documentation to Python · inheritance between
dataclasses puts base fields first, so a base with defaults forces defaults on every subclass field after
it.@dataclass reads the annotations and generates
__init__, __repr__ and __eq__ as ordinary methods — the same code you
would write, minus the chance of forgetting one when a field is added · frozen=True makes it
immutable and hashable; a plain mutable dataclass is deliberately unhashable · order=True
sorts by fields in declaration order, so declaration order is a decision · a mutable default is shared by every
instance, which is why field(default_factory=list) exists — and why the decorator refuses a list
literal outright · replace() and asdict() come along for free. Next:
Counter, pathlib & itertools.Second opinion (taught here — these corroborate): dataclasses docs · PEP 557 · pydantic.