Skip to content

← Data Modelling and Invariants step 18 of 25

Easy Primitives

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)make runs on every construction even though the field is not a constructor parameter. If make opens 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:

  1. default= — flag it unless the value is genuinely immutable. Treat None, bool, int, float, complex, str, bytes and type as atoms; tuple and frozenset are immutable only if every element is. Anything else is shared mutable state, regardless of whether it is hashable.
  2. default_factory= — flag it only if calling the factory twice returns the same object (is). list, dict and set pass; 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.