Default argument values are evaluated once at function definition time. Mutable defaults (list, dict, set) become shared state across calls, producing cross-request contamination and "sometimes wrong" test failures.
# typing makes intent cleardef process(items: list[int] | None = None) -> list[int]: working = list(items) if items is not None else [] working.append(1) return working
Why does Python evaluate defaults at definition time?
Defaults are stored on the function object (__defaults__, __kwdefaults__) for performance and introspection. Mutable objects should not be used as those stored values.
Is `def f(x=time.time())` safe?
No. The timestamp is fixed at import/definition. Pass None and compute time.time() inside the function body.
How do I find these bugs in a large codebase?
Search for = [], = {}, = set() in def lines and dataclass fields without default_factory. ruff 0.9+ rules flag some patterns.
Does asyncio change this?
The same function object is shared across tasks on one event loop. Mutable defaults leak state between concurrent requests in one process.
What about default `list` in Pydantic models?
Pydantic 2 uses default_factory internally when you use Field(default_factory=list). Plain items: list = [] in raw dataclasses is still unsafe.
Can I use `bucket=[]` if I never mutate?
If you truly never mutate, a tuple default is clearer. Empty list defaults still confuse readers and linters.
Why not `bucket = bucket or []`?
An empty list [] is falsy; callers passing [] would get a new list unexpectedly. is None checks only the sentinel.
Are module-level globals better?
No. Globals create the same shared-state problems with worse testability. Prefer explicit parameters or instance attributes.
How do I debug a suspected shared default?
Print id(store) across calls without passing store. Identical IDs confirm the shared default object.
Do Flask `g` or FastAPI request state replace this pattern?
Request-scoped state belongs on g/request objects, not function defaults. Defaults are process-wide, not request-wide.
What about `functools.partial` with lists?
Partial binds arguments early; ensure bound mutable objects are not shared unintentionally across partials.
Is deepcopy on default a fix?
copy.deepcopy per call works but is slower and obscure. None sentinel is idiomatic and faster.