We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 18 of 25
The mutable-default trap — and why the guard is weaker than you think
A shared mutable default is a cross-request data leak that survives every unit
test that constructs one object. The test builds a Session(), appends to
session.events, asserts, and passes. In production the second request gets the
first request’s events, because both objects point at the same list — the one
built once, at class-definition time.
The guard is weaker than you think
dataclasses does refuse some mutable defaults. But since 3.11 the rule is
not “is it a list, dict or set”. It is:
if f.default.__class__.__hash__ is None: raise ValueError(...)
The docs are candid about why: “unhashability is used to approximate
mutability… this is a partial solution”. So the check is a proxy, and the proxy
is wrong in the direction that hurts. Write a class with a real __hash__ and a
mutable body:
class Bag:
def __init__(self) -> None:
self.items: list[str] = []
def __hash__(self) -> int:
return 0
@dataclass
class Leaky:
bag: Bag = Bag() # accepted. one Bag. shared by every instance.
No ValueError, no mypy error, no ruff error. One Bag, for the lifetime of the
process. This is not exotic — any object with __hash__ inherited from a base
that defines it, or any @dataclass(frozen=True) wrapper around a mutable
payload, lands here.
default_factory is not automatically safe either
field(default_factory=list) is safe because list() returns a new list.
field(default_factory=lambda: _CONFIG_ROWS) is not — the callable is invoked on
every construction and returns the same object every time. The only reliable
test is behavioural: call it twice and compare identity.
Two more shapes worth naming:
-
def f(x: list[int] = []) -> None:— the plain-function version. Same object for the life of the module.def f(t: datetime = datetime.now())is worse: the timestamp is frozen at import time and every “now” in that function is the moment your process booted. -
field(init=False, default_factory=make)—makeruns on every construction even though the field is not a constructor parameter. Ifmakeopens a connection, you just opened one per object.
What the type checker does about it
Nothing. mypy --strict accepts
@dataclass
class C:
xs: list[int] = []
with no diagnostic; you find out at import time with a ValueError. This is the
first hard lesson of the course: the strict gate is necessary, not
sufficient. The static backstop is ruff — RUF008 (mutable dataclass default)
and RUF009 (function call in a dataclass default) — and both are heuristics
over syntax, so neither catches the Bag above.
Your task
Implement:
def audit_defaults(cls: type[DataclassInstance]) -> list[str]:
returning the sorted names of every field whose default is shared mutable
state — the check dataclasses should have made. Two rules:
-
default=— flag it unless the value is genuinely immutable. TreatNone,bool,int,float,complex,str,bytesandtypeas atoms;tupleandfrozensetare immutable only if every element is. Anything else is shared mutable state, regardless of whether it is hashable. -
default_factory=— flag it only if calling the factory twice returns the same object (is).list,dictandsetpass; a factory closing over a module-level list does not.
solve(target) looks the class up in REGISTRY and returns the report.
Note the recursion in rule 1. (1, "x") is safe. (1, Bag()) is not — a
tuple is only as immutable as its contents, which is also why it can be
unhashable at runtime while looking hashable in the annotation.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.