A completely ordinary function
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
Tags a piece of content with a label, defaulting to a fresh empty list if you don't pass one in. Reasonable-looking signature, the kind you'd write without a second thought.
Three calls, three unrelated pieces of content
add_tag("draft")
add_tag("urgent")
add_tag("reviewed")
Predict the three return values before reading on. Each call passes a genuinely different, unrelated tag, and none of them pass their own tags list.
What actually comes back
call 1: ['draft']
call 2: ['draft', 'urgent']
call 3: ['draft', 'urgent', 'reviewed']
The second call's result includes the first call's tag. The third includes both. Nothing connects "urgent" and "reviewed" to "draft" in the code that calls this function, they're three separate calls for three separate pieces of content, and yet the list keeps growing across all of them.
Why
add_tag.__defaults__[0] is add_tag.__defaults__[0]
# True
add_tag.__defaults__
# (['draft', 'urgent', 'reviewed'],)
Default argument values are evaluated exactly once: when the def statement runs, not each time the function is called. [] is a real, mutable object, and Python builds that one specific list a single time, at definition time, and reuses the exact same object as the default on every subsequent call that omits tags. .append() mutates it in place. There's no per-call reset anywhere in this mechanism, because nothing ever asked for one.
The fix
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tags
None is immutable, so it's safe to reuse as a default. The real, fresh list only gets built inside the function body, on every call, exactly when you'd expect a "give me an empty list" default to actually happen.
The same mechanism, used on purpose
This isn't always a bug waiting to happen. The identical "one object, built once, shared across calls" behavior is a real, legitimate technique for a simple cache:
def cached_lookup(key, _cache={}):
if key not in _cache:
_cache[key] = key.upper()
return _cache[key]
first call: HELLO (real cache miss, computed)
second call, same key: HELLO (found in the shared dict, no recomputation)
Same trick, opposite intent: the leading underscore signals "this parameter isn't meant to be passed in," and the persistence across calls is exactly the point rather than an accident. The difference between this and the tagging bug isn't the mechanism, it's whether the function's author meant for state to survive between calls.
The takeaway
Any mutable default argument, a list, a dict, a set, a custom object, behaves the same way: built once, shared forever, until something explicitly resets it. Whether that's a bug or a feature depends entirely on whether the shared state was the point. Check any function signature with =[], ={}, or a bare mutable object as a default, and ask which one it's supposed to be.
Comments
Loading comments...