WHY start here. The decorator is a function that receives this class after Python has read its body. So the only thing it has to work with is what's written between the colons — the annotations and any default values. If we understand exactly what data is sitting there, we can predict everything the robot produces.
PICTURE. Each line becomes a labelled slot. The accent (red) marks the one line with a default value (label = "P") — remember it, it changes the assembly later.
Read the picture as: three sticky notes (x, y, label), each with a type, and one of them (red) also carrying a pre-filled value.
Every term: the keys ("x", "y", "label") are the field names; the values (float, str) are the type labels the decorator uses only to know "yes, this is a field."
WHY order matters. The generated __init__ will list its parameters in this exact order. If the dict were unordered, Point(1, 2) wouldn't know whether 1 is x or y. Since Python 3.7 the annotation dict preserves source order for free — that is why the field order equals the line order you typed.
PICTURE. The three sticky notes drop onto a numbered conveyor belt. The number is the parameter position they will occupy.
WHAT. Walking the belt left to right, the robot asks one yes/no question per field: "Is there a class-level value for this name?"
x → no value → required parameter.
y → no value → required parameter.
label → value "P" exists → optional parameter with default "P".
WHY. Python's rule for function parameters is iron-clad: every parameter with a default must come after every parameter without one. So the split isn't cosmetic — it decides whether the class is even legal.
PICTURE. The belt splits into two lanes: black "required" lane (no fill) and red "has default" lane. label moves to the red lane. Notice all reds sit to the right of all blacks.
Every term: self is the fresh, empty object being filled; each self.name = name copies the incoming argument onto the object so it survives after __init__ returns.
WHY generate real code (instead of looping over fields at runtime every time)? Because this welds once, at class-creation time, into a plain fast function. Every Point(...) call afterwards is as cheap as a hand-written __init__.
PICTURE. Arrows carry each belt slot into a matching parameter box; then a second arrow set copies parameter → self. attribute. The red arrow is the defaulted label, showing its default value riding along.
WHAT. Suppose a field's default is a list: items: list = []. The robot stops and raises ValueError at class-creation time.
WHY. A default value written in the class body is created once and would be shared by every instance — change one cart's list, every cart changes. This is the classic mutable-default trap, and dataclasses refuse it outright instead of letting it bite you later. The fix is a factory:
a callablefield(default_factory=list)called once per __init__fresh list per object[]
Term-by-term: default_factory holds a function (here list); the generated __init__calls it anew on every construction, so each object gets its own independent list.
PICTURE. Left: one shared red list wired to three objects (the danger). Right: three separate red lists, one per object (the factory fix).
WHAT. Still at class-creation time, the robot also welds:
__repr__ → returns the string "Point(x=1, y=2, label='P')".
__eq__ → compares the tuple of fields:
this object’s fields(self.x,self.y,self.label)==other object’s fields(other.x,other.y,other.label)
WHY a tuple? Python already knows how to compare tuples element by element. By packing the fields into a tuple, the robot gets correct field-by-field equality for free, in one line, with no manual loop.
WHY this beats a plain class: a plain class's default __eq__ compares identity (are these the same object in memory?), so two identical-looking points would be "not equal". The generated one compares values.
PICTURE. Two separate memory boxes (different addresses) whose field tuples are identical; the red == bridge says "equal by value" even though the addresses differ.
WHAT. If you defined a method named __post_init__, the generated __init__ calls it as its very last line, after every self.name = name assignment. This is the hook for validation and for derived fields (fields you compute, not receive).
__init__:assign all fields firstself.width=...;self.height=...⟶your code, lastself.__post__init__()
WHY last? Your derived logic needs the plain fields already set. In area = width * height, both self.width and self.height must already exist — and they do, because assignment happens before the call. A field marked field(init=False) is skipped in the parameter list precisely so you can fill it here.
PICTURE. A timeline arrow: assignments fill in one by one (black), then the red __post_init__ box fires at the end, reading the black values to compute the red derived value.
WHAT. With @dataclass(frozen=True) the robot replaces every self.x = x with a locking assignment and refuses all later writes via FrozenInstanceError. It also welds a __hash__.
WHY a hash appears only when frozen. A hash is a fingerprint used by dicts and sets. If a field could change, the fingerprint would go stale and the object would get "lost" inside a set. Mutable dataclasses therefore set __hash__ = None (unhashable). Freeze the fields → fingerprint can't go stale → Python safely generates __hash__. This is the Immutability and Hashing rule in action.
THE CATCH inside __post_init__. The lock blocks all writes, even your derived-field write. To set one derived field you must bypass the lock explicitly:
PICTURE. A padlocked object: normal self.x = ... arrows bounce off (crossed out), but the single red object.__setattr__ arrow slips through the one keyhole.
The whole pipeline on one belt. Fields collected → defaults split → __init__ welded → __repr__/__eq__ welded → __post_init__ fires last. Mutable-default and frozen are the two side-doors.
Recall Feynman retelling — explain the whole walkthrough in plain words
A robot stands at a conveyor belt. You hand it a class body full of sticky notes (x: float, y: float, label = "P"). The robot reads the notes in order (Step 2), sorts them into "you must give me this" versus "I have a default" (Step 3), and then welds a real __init__ function that copies each argument onto the new object (Step 4). If any default is a shared mutable thing like [], it slams the emergency stop and tells you to use a factory instead (Step 5). It throws in two freebies: a nice printout (__repr__) and a value-based equality check that packs the fields into a tuple (Step 6). If you left a note called __post_init__, it runs that last of all, once every field is already set, so you can validate or compute extras like area (Step 7). And if you asked for frozen=True, it welds a padlock on the object plus a stable fingerprint (__hash__) so it can live in sets — with one tiny keyhole (object.__setattr__) for derived fields (Step 8). That is the entire magic: fields in, dunders out, your checklist runs at the very end.
Recall Quick self-test
Predict the generated __init__ signature of Point ::: def __init__(self, x: float, y: float, label: str = "P") — field order, defaults last.
Why does the field order in __init__ equal the source order? ::: __annotations__ preserves insertion order, and the decorator walks it in that order.
When exactly does __post_init__ run? ::: As the last statement of the generated __init__, after every field is assigned.
Why can only frozen dataclasses be dict keys? ::: Their fields can't change, so __hash__ stays valid; mutable ones set __hash__ = None.
Related: NamedTuple vs Dataclass vs TypedDict · Type Annotations in Python · OOP Fundamentals