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.