Run it
The accumulation, the shared object read straight off the function, and the two-line fix - plus the same bug wearing a clock.
The accumulation, and the shared object read straight off the function.
The fix is two lines, and a private sentinel covers the case where None is itself a legitimate value.
The same rule with no list in sight, which is how it reaches production.
Defaults are evaluated once
A def statement is executable code. When it runs, Python evaluates the default expressions and stores the resulting objects on the function — you can read them back from f.__defaults__. They are not re-evaluated per call, because there is nothing left to evaluate.
For an immutable default that distinction is invisible: sharing one 0 or one "" between calls has no consequences, because nothing can change it. For a list, a dict, a set or a class instance it is the whole bug, because the shared object accumulates every mutation any call makes.
The sentinel, and why None
The fix is two lines:
def collect(item, into=None):
if into is None:
into = []
Now the list is built by the body, so each call gets its own. None is the conventional sentinel because it is a singleton and is None is unambiguous.
Use a private sentinel object instead when None is a legitimate value a caller might pass — _MISSING = object(), then if into is _MISSING. That distinction matters for wrappers and configuration functions where "not given" and "given as None" mean different things.
Where it actually bites
Almost never with a literal [], because that is the version everybody has been warned about. It bites through things that do not look like defaults at all:
def f(when=datetime.now()) freezes one timestamp at import time and returns it forever, which is the same bug wearing a clock. def f(cfg={}) in a class body gives every instance the same dictionary. And a default whose value comes from a module-level mutable — def f(opts=DEFAULT_OPTS) — hands callers a reference they can mutate for everyone.
The general rule that covers all of them: a default should be an immutable value, or None.
Why the language does not just fix it
Re-evaluating defaults per call would make every call pay for the expression, and it would make the value depend on when the call happened rather than on the definition — which is its own class of surprise. Early binding is also what makes the lambda i=i: i trick work for late-bound closures, where evaluating once at definition time is exactly the behaviour you want. It is a consistent rule that is wrong for one kind of value, rather than an oversight.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
Passing your own argument leaves the shared default untouched, which is exactly why the bug survives testing: any test that supplies the list never sees it.
The shared object is per function, not per module or per instance. A method with a mutable default shares one object across every instance of the class, which looks like a class attribute bug and is not.
Mutating an argument the caller passed is the same class of surprise, without any default involved. If a function appends to a list it was given, say so in the name or the docstring - or copy on the way in.