Why does `def add(x, bucket=[])` accumulate values across separate calls in Python?
answer
- The signature runs before any call does
- One object, stored on the function
- Omitting the argument reuses it
- Check identity against __defaults__
- Same rule freezes computed defaults
basics
~20 sPython evaluates the empty-list default once, while the def statement runs, and stores that one list on the function object. Every call that omits bucket appends to that same list, so values from earlier calls are still there.
solid answer
~40 sA default expression is evaluated when the `def` statement executes, not on each call. The resulting object is attached to the function object — in the `__defaults__` tuple for positional-or-keyword parameters — and every call that omits the argument binds the parameter to that one object. So `bucket.append(x)` mutates a list whose lifetime is the function's lifetime, and the second call sees the first call's data; you can prove it because the returned list *is* `add.__defaults__[0]`. Passing the argument explicitly is unaffected. The fix is `bucket=None` plus `if bucket is None: bucket = []` in the body. The same rule explains two subtler bugs: a computed default like `at=time.time()` freezes the import-time value, and a resource created in a signature is opened once and never closed.
code
pycon · 12 lines>>> def add(x, bucket=[]):
... bucket.append(x)
... return bucket
...
>>> add(1)
[1]
>>> add(2)
[1, 2]
>>> add(3, [])
[3]
>>> add.__defaults__
([1, 2],)go deeper
Recall the rule and the fix: the default is created once when def runs, so default to None and build the list in the body. Being able to state it plainly is enough at this level.
This is your question. Explain def-time evaluation, that the object lives in __defaults__ on the function, that explicit arguments bypass it, and demonstrate the identity check rather than only the accumulating output.
Show the failure modes past the demo: a timestamp frozen at import, a connection opened in a signature and never closed, a shared cache that grows for the process lifetime. Say how review and lint catch it before production.
Frame it as an API-contract issue: a mutable default makes a pure-looking function stateful across the whole process, which is invisible at the call site. Decide what your codebase's lint gate blocks and what it merely warns on.
### What actually happens ```python def add(x, bucket=[]): bucket.append(x) return bucket add(1) # [1] add(2) # [1, 2] ``` When Python executes the `def` statement — once, when the module is imported or the enclosing scope runs — it evaluates every default expression in the signature *in the enclosing scope*, collects the resulting objects, and attaches them to the newly created function object. Positional-or-keyword defaults land in the `__defaults__` tuple; keyword-only defaults land in the `__kwdefaults__` dictionary. The `def` statement then binds the function object to the name. From that point the defaults are fixed objects. A call that omits `bucket` does not re-run `[]`; the interpreter simply binds the parameter to the object already sitting in `__defaults__`. So every default-using call shares one list, and `bucket.append(x)` mutates a list whose lifetime is the lifetime of the function object — usually the lifetime of the process. You can watch it happen without any tooling: ```python add(1) add.__defaults__ # ([1],) add(2) is add.__defaults__[0] # True ``` The returned list *is* the stored default. That identity check is the cleanest way to demonstrate the mechanism at a whiteboard, and it also explains a symptom that confuses people: passing the argument explicitly is unaffected — `add(3, [])` binds the parameter to the caller's fresh list and leaves the stored default alone. ### Why the language does it this way A default is an ordinary expression evaluated in the scope where the `def` appears, at the moment the `def` runs. That is consistent with how Python evaluates every other expression: there is no implicit laziness anywhere in the language. Deferring evaluation to call time would need the interpreter to keep the *unevaluated* expression plus the scope to evaluate it in, and would change what names it can see and when side effects happen. A late-bound-default syntax has been proposed (PEP 671, which would spell it with a `=>` marker) but it has never been accepted and is not in any released Python, 3.14 included. Assume def-time evaluation, always. It is also worth knowing that this is not a bug being tolerated — it is what makes a default able to capture a value deliberately, and it is fast: no expression is re-evaluated per call. ### The failure modes beyond accumulation Accumulation is the demo, but the same rule produces two other production bugs. A **frozen computed value**: `def stamp(event, at=time.time()):` records the time the module was imported for the rest of the process. In a nightly inventory sync between two systems, every record ends up stamped with process start rather than the moment it was reconciled, and the downstream system silently treats the whole batch as simultaneous. A **resource created at import and never closed**: `def sync(records, session=make_session()):` opens a connection while the module loads. It is shared by every call, nobody owns closing it, and because it never appears in a `with` block it survives every code path — including the ones that raise. On an 11-person team the person who writes the default and the person who debugs the lingering socket are rarely the same person, which is exactly why lint rules exist for this. ### The fix, and the near-miss fixes The fix is to default to `None` and construct inside the body: ```python def add(x, bucket=None): if bucket is None: bucket = [] bucket.append(x) return bucket ``` Two attempts that look like fixes but are not: calling `bucket.clear()` at the top of the body does reset the shared default, but it also empties a list a caller passed in — a much worse bug; and writing `bucket = bucket or []` replaces an explicitly-passed empty list, so the caller's object never receives the append. Rebinding `bucket = []` unconditionally at the top just makes the parameter useless. Immutable defaults need none of this. `def add(x, bucket=())` shares a tuple, and since a tuple cannot be appended to, sharing is invisible; you would build a new tuple with `bucket + (x,)`. ### What an interviewer is really testing The question is a proxy for "do you know when the expressions in a signature run?" A candidate who answers only "never use a mutable default" has memorized a rule. A candidate who says defaults are evaluated once at `def` time, stored in `__defaults__`, shared by every default-using call, and that the same rule explains stale timestamps and import-time resources, has the model — and will recognize the next variant of the bug instead of just the list one.
- How would you prove at the REPL that two calls used the same list object?Call the function twice without the argument and compare identity: `add(1) is add(2)` is `True`, and `add(1) is add.__defaults__[0]` is `True` as well. `id()` on the two returned lists gives the same value. That identity check is stronger than showing the accumulated contents, because it names the mechanism rather than the symptom.
- Does the same evaluation rule apply to keyword-only parameters?Yes, identically. Their evaluated defaults are stored in the function's `__kwdefaults__` dictionary instead of the `__defaults__` tuple, but they are still computed once when the `def` statement runs and shared by every call that omits them. Making a parameter keyword-only changes how it is passed, never when its default is built.
- Why doesn't Python simply evaluate defaults on every call?Because a default is an ordinary expression evaluated in the enclosing scope, and Python has no implicit laziness: deferring it would mean storing the unevaluated expression plus the scope to run it in, changing which names it sees and when side effects happen. It would also cost a re-evaluation per call. A late-bound-default syntax was proposed in PEP 671 and never accepted.
saying these in an interview costs you the question
- Says the default list is rebuilt each call and blames caching
- Describes it as a global variable leaking into the function
- Thinks passing bucket explicitly still touches the shared list
- Claims only lists are affected, not dicts, sets or instances
- Fixes it with bucket.clear(), which empties a caller's list
- Believes a fresh import or a decorator resets the default