2.1.16 · D5OOP Fundamentals

Question bank — Dataclasses — `@dataclass` decorator, `__post_init__`

1,423 words6 min readBack to topic

True or false — justify

@dataclass enforces the declared types at runtime, so Point(x="hi") raises a TypeError.
False. Annotations like x: float are only signals used to discover fields; Python never checks the value's type at construction. See Type Annotations in Python — annotations are metadata, not runtime guards.
A bare x = 5 with no annotation becomes a dataclass field with default 5.
False. The decorator finds fields by reading __annotations__; an un-annotated assignment is just an ordinary class attribute and is completely invisible as a field.
Two dataclass instances with identical field values are equal by default.
True. The generated __eq__ compares the tuple of fields, so equal values give equality — unlike a plain class whose default __eq__ compares identity (memory address).
@dataclass always generates __hash__.
False. Only frozen=True (or eq=False) yields a hash; a normal mutable dataclass sets __hash__ = None because its values could change and break dict/set invariants. See Immutability and Hashing.
field(init=False) fields still appear in the generated __repr__.
True. init=False only removes the field from __init__'s parameter list; it is still a field, so it shows in repr and participates in __eq__ (unless you also pass repr=False/compare=False).
__post_init__ runs before the fields are assigned, so you can pre-process the raw arguments.
False. It is called as the last statement of __init__, after every field is already assigned — that is why self.temp is available for computing derived values inside it.
Making a dataclass frozen=True also makes it faster.
False. frozen=True is about immutability and hashability, not speed; it actually adds a small cost per attribute write attempt (each self.x = ... is routed to raise FrozenInstanceError).
Inheriting from a dataclass and adding a field with a default while the parent's last field has no default works fine.
False. Field order is inherited first, so a non-default parent field would follow a defaulted child field only if you're careful — mixing defaulted then non-defaulted fields (across parent+child) raises a TypeError for the same "non-default after default" reason.

Spot the error

@dataclass
class Cart:
    items: list = []
What breaks and why?
Python raises ValueError at class-creation time — a single list literal would be shared across all instances (the Default Arguments and Mutable Default Trap). Fix: items: list = field(default_factory=list).
@dataclass(frozen=True)
class Box:
    w: float
    volume: float = field(init=False)
    def __post_init__(self):
        self.volume = self.w ** 3
Why does construction fail?
frozen=True blocks all attribute writes, including self.volume = ... inside __post_init__, raising FrozenInstanceError. Fix: object.__setattr__(self, "volume", self.w ** 3).
@dataclass
class User:
    name: str = "guest"
    id: int
What's wrong?
A field with a default (name) is followed by one without (id), so the generated __init__ would have a non-default parameter after a default one — Python raises TypeError at class creation. Put non-defaulted fields first.
@dataclass
class Node:
    value: int
    def __post_init__(self):
        self.value = value * 2
Why does this raise NameError?
Inside __post_init__ there is no local value parameter — the raw arg was already stored on the instance. You must reference self.value, not the bare name value.
@dataclass
class Config:
    tags = ["a", "b"]
Why does Config() fail to hold tags as a field?
tags has no annotation, so it is not a field at all; Config() takes no arguments and tags stays a shared class attribute — no ValueError is even raised because the decorator never sees it.
@dataclass
class Temp:
    celsius: float
    fahrenheit: float
    def __post_init__(self):
        self.fahrenheit = self.celsius * 9/5 + 32
What subtle bug does the caller hit?
fahrenheit is still a normal __init__ parameter, so Temp(100) fails (missing arg) and Temp(100, 0) silently accepts a bogus 0 that __post_init__ overwrites. Use fahrenheit: float = field(init=False).

Why questions

Why does the decorator generate __init__ as a string of source code rather than using reflection each call?
A compiled function runs at native speed and behaves exactly like a hand-written __init__, whereas reflecting over fields on every construction would be slow and harder to debug.
Why does __eq__ compare a tuple of fields instead of comparing each field in a loop?
Tuple comparison already does lexicographic, field-by-field equality for free and short-circuits, so (self.x, self.y) == (other.x, other.y) is both concise and correct.
Why does a plain (non-dataclass) class print False for two instances with identical data?
Without a generated __eq__, Python falls back to identity comparison (is), which is true only when both names point to the exact same object in memory. See Dunder Methods __init__ __repr__ __eq__.
Why must mutable dataclasses set __hash__ = None rather than just hashing the current field values?
A hash must stay constant for an object's lifetime; if a mutable field changed, the object would land in the wrong hash bucket, silently corrupting dicts and sets. See Immutability and Hashing.
Why is __post_init__ the only clean place for validation, given you didn't write __init__?
Because the generated __init__ does nothing but assignment, the hook it calls at the very end is the single point where all fields are guaranteed set and available to check or derive from.
Why does default_factory take a callable instead of a value?
A callable is invoked once per instance inside __init__, so each object gets a freshly built list/dict — a plain value would be a single shared object, reviving the mutable-default trap.
Why choose a frozen dataclass over a NamedTuple when you need immutability?
A frozen dataclass gives immutability plus __post_init__ validation, default_factory, and inheritance; NamedTuple gives immutability and tuple-unpacking but no validation hook and awkward defaults.

Edge cases

What happens if you define __post_init__ but the class has no field(init=False) fields and no validation — is it still called?
Yes. If __post_init__ exists, the generated __init__ always calls it as its last line, regardless of whether it does anything useful.
If you pass eq=False to @dataclass, can the class still be hashable?
Yes — turning off __eq__ leaves the default identity-based __eq__/__hash__ inherited from object, so instances are hashable by id (but compare by identity, not value).
What does Rectangle(0, 5) do if __post_init__ validates width > 0?
All fields are assigned first, then __post_init__ runs and raises ValueError; the object is never returned to the caller, so no half-built instance escapes.
Can two frozen=True instances with equal fields be used as the same dict key?
Yes — equal fields give equal __eq__ and equal __hash__, so {a: 1}[b] succeeds when a == b; that value-based identity is exactly why frozen dataclasses are dict-safe. See Immutability and Hashing.
Does field(default_factory=...) and default=... on the same field work together?
No — supplying both raises ValueError at class creation, because Python can't decide whether to use the fixed value or call the factory; pick exactly one.
If a subclass redeclares a parent field with a new default, which default wins?
The subclass's default wins, but the field keeps its original position from the parent's declaration order, which can surprise you when defaults/non-defaults interleave.
What is the value of a field(init=False) field if __post_init__ never assigns it and it has no default?
Accessing it raises AttributeErrorinit=False with no default and no assignment means the attribute simply never gets created.