@dataclass writes __init__, __repr__ and __eq__ for you from a list of annotated fields. It saves a lot of boilerplate, but it also makes decisions about mutability and hashing that aren’t obvious from the decorator line. Interview questions here usually start with “why does this raise?” and end with “so how would you model an immutable value object?” Everything below was run on Python 3.14.
The list default that won’t compile
Write a cart with an empty list as its default and the class definition itself fails:
from dataclasses import dataclass
@dataclass
class Cart:
items: list = []ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryThis is the dataclass version of the mutable default argument bug. The generated __init__ would use that single list as the default for every instance, so every cart would share one list. Dataclasses refuse unhashable defaults (lists, dicts, sets, and since 3.11 any unhashable object) to stop that before it starts.
The fix is a factory, which is called once per instance:
from dataclasses import dataclass, field
@dataclass
class Cart:
owner: str
items: list[str] = field(default_factory=list)
a, b = Cart("ana"), Cart("ben")
a.items.append("book")
print(a, b)Cart(owner='ana', items=['book']) Cart(owner='ben', items=[])The check is a safety net, not a guarantee. It looks only at whether the default is hashable. A default that’s a hashable object holding mutable state (a custom class with __hash__ defined, say) slips through and gets shared. The underlying rule hasn’t changed: a default expression is evaluated once, when the class is created.
Equality, hashing, and why instances can’t be keys
By default @dataclass sets eq=True, generating an __eq__ that compares fields. Python’s rule is that objects that compare equal must have equal hashes. A mutable object whose fields can change would break that rule as soon as it changed while sitting in a dict, so the dataclass sets __hash__ to None:
@dataclass
class Point:
x: int
y: int
print(Point.__hash__)
{Point(1, 2): "home"}None
TypeError: cannot use 'Point' as a dict key (unhashable type: 'Point')That TypeError surprises people who know plain classes are hashable. A plain class uses identity for both == and hash, so it’s consistent. Once you define field-based equality, identity-based hashing would be wrong, and field-based hashing would be dangerous on a mutable object.
You can force it with unsafe_hash=True, and the name tells you what you’re agreeing to. If you mutate a field of an instance that’s already a dict key, the dict looks in the wrong bucket and can’t find it. The hashability guide walks through that failure.
frozen=True: what it blocks and what it doesn’t
frozen=True makes the generated class raise on attribute assignment, and because instances can’t be reassigned, the dataclass also generates a field-based __hash__:
from dataclasses import dataclass, replace, FrozenInstanceError
@dataclass(frozen=True)
class Money:
amount: int
currency: str
m = Money(500, "EUR")
m.amount = 600 # raisesFrozenInstanceError: cannot assign to field 'amount'Instances work as dict keys, and you “change” one by creating a new one with dataclasses.replace:
print({m: "price"}, replace(m, amount=600), m){Money(amount=500, currency='EUR'): 'price'} Money(amount=600, currency='EUR') Money(amount=500, currency='EUR')Here’s the part interviewers listen for. Frozen is shallow. It stops obj.field = value, but it does nothing about the object already stored in the field:
@dataclass(frozen=True)
class Order:
id: int
lines: list[str]
o = Order(1, ["book"])
o.lines.append("pen")
print(o)
hash(o)Order(id=1, lines=['book', 'pen'])
TypeError: unhashable type: 'list'The order changed after construction, and hashing fails because the generated __hash__ hashes a tuple of the fields, which includes a list. So a frozen dataclass is only truly immutable if its fields are immutable too.
To build a real value object, store immutable types. Convert in __post_init__, which runs at the end of the generated __init__. Since normal assignment is blocked in a frozen class, use object.__setattr__:
@dataclass(frozen=True)
class Order:
id: int
lines: tuple[str, ...]
def __post_init__(self):
object.__setattr__(self, "lines", tuple(self.lines))
o = Order(1, ["book", "pen"])
print(o, hash(o) == hash(Order(1, ("book", "pen"))))Order(id=1, lines=('book', 'pen')) TrueCallers can pass a list for convenience, and the object they get back can’t be changed through it. The same idea applies to dicts (types.MappingProxyType or a tuple of pairs) and sets (frozenset). A side benefit: an instance built entirely from immutable parts can be shared between threads or cached globally without anyone worrying that a caller will change it underneath them.
Validation lives in post_init, and only there
__post_init__ is also where validation goes. The catch with a mutable dataclass is that it runs once:
@dataclass
class Account:
balance: int
def __post_init__(self):
if self.balance < 0:
raise ValueError(f"balance must be >= 0, got {self.balance}")
acc = Account(10)
acc.balance = -5
print(acc)Account(balance=-5)The invariant was checked at construction and then quietly broken by an assignment. If an invariant must always hold, you have three options. Make the class frozen so there’s no assignment to bypass the check. Expose changes only through methods (acc.withdraw(15)) that check before changing state. Or use a property with a setter that validates, accepting that you’re now writing some of the boilerplate dataclasses were meant to remove. For data parsed from outside (API payloads, config files), a validation library such as Pydantic is often a better fit, since validation on every assignment is its main job.
A few more options worth knowing: slots=True (3.10+) generates __slots__, which saves memory and makes a typo like acc.balnce = 5 raise AttributeError instead of silently adding a new attribute. kw_only=True forces keyword arguments, which keeps call sites readable when a class has many fields of the same type.
Interview practice: a cache keyed by a dataclass
A service caches prices in a dict keyed by @dataclass(unsafe_hash=True) class Query: sku: str; region: str. A code path builds a Query, stores a price under it, then sets query.region = "EU" for the next lookup. Later, lookups for the original key miss and the dict seems to hold a key that can’t be found. Explain and fix it.
Answer
unsafe_hash=True generated a hash from the current field values. When the object went into the dict, it was stored in the bucket for its hash at that moment. Changing region changed its hash, but the dict doesn’t know; the entry stays in the old bucket. Looking up with a fresh Query(sku, old_region) finds the right bucket, but the stored key now compares unequal (its region is "EU"), so it misses. Looking up with the mutated object computes the new hash and checks the wrong bucket. The entry is effectively orphaned: running this on 3.14, both Query("a", "US") in cache and query in cache return False even though the dict has one entry. Nothing raises, which is why this kind of bug shows up as a falling cache hit rate rather than an error. The fix is to make the key immutable: @dataclass(frozen=True), and use replace(query, region="EU") when you need a variant. A frozen key can’t drift after insertion, and you drop the unsafe_ flag, which was the warning sign all along.