The mutable default argument, and other Python footguns
Python evaluates default argument values exactly once, at function definition time, which means a mutable default is a shared object that silently accumulates state across every call.
There is a bug that has probably shipped in more Python codebases than any other single mistake. It does not raise an exception. It does not appear on the first call, or even the second. It surfaces somewhere around the third call, in production, when a list that should have been empty arrives with two entries already in it and nobody can explain where they came from.
The bug is four characters: = [].
def append_item(item, items=[]):
items.append(item)
return items
Call this three times and watch what happens.
append_item("a") # ["a"]
append_item("b") # ["a", "b"]
append_item("c") # ["a", "b", "c"]
Every call returns a longer list. The list is growing. You never passed a list in. You never asked it to remember anything.
The moment the default is born
The reason is precise and, once you see it, obvious: Python evaluates default argument expressions once, when the def statement itself executes. Not when you call the function. Not each time a caller omits the argument. Once, at definition time, full stop.
So items=[] does not mean “use a fresh empty list if the caller omits this argument.” It means “evaluate [] right now, store the resulting list object on the function object, and hand that same object to every caller who does not supply their own.”
The list object lives on the function itself. You can see it directly:
append_item.__defaults__ # (["a", "b", "c"],)
There it is. One list. Shared. Mutating every time you call append_item without an explicit second argument.
This is not a bug in Python. It is a deliberate design: default values are stored as a tuple on the function object, computed at definition time, because that is the only moment when the surrounding scope is available and trustworthy. The problem is purely that [] creates a mutable object, and mutating shared state is always surprising.
The diagram tells the story
The fix, and why it looks the way it does
The idiomatic repair is so common it reads like a ritual:
def append_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
None is a singleton — it is always the same object, it is immutable, and it carries no state. The def statement evaluates None as the default, which is perfectly safe. The freshness happens inside the body, where it belongs: a new [] is created each time the function runs without an explicit second argument.
The use of is None rather than == None matters here. is tests object identity — it asks whether two names point to the same object in memory. == None tests equality and can theoretically be overridden by a class’s __eq__ method. For sentinel checks, identity is what you mean, and is says it clearly.
That is versus == distinction is worth a small detour.
is versus ==: the interning trap
Consider this:
a = 256
b = 256
a is b # True
a = 257
b = 257
a is b # False (CPython, at module scope)
Wait. Both pairs look identical. Why does identity differ for 257?
CPython (the standard implementation) interns (pre-creates and caches) integers from -5 through 256. These are used so frequently that allocating a new object for each one would be wasteful. So when you write 256, you get back a reference to the same cached object every time. But 257 falls outside the cache — CPython allocates a fresh integer object each time, so two different 257 literals are two different objects.
String interning follows similar logic: short strings that look like identifiers are usually interned; longer or dynamically-constructed strings might not be. The practical consequence is that is on strings gives you inconsistent results depending on how and where the string was built.
The rule is simple: use == when you mean “same value,” use is only when you mean “literally the same object.” Legitimate uses of is are essentially two: checking is None (or is not None) and checking against other explicit singletons like True, False, or a sentinel you defined yourself.
Late-binding closures: the loop footgun
The mutable default bug has a close cousin in closures. Consider building a list of functions in a loop:
funcs = []
for i in range(3):
funcs.append(lambda: i)
[f() for f in funcs] # [2, 2, 2]
Every function returns 2. Not 0, 1, 2 as you might expect. All three lambdas close over the variable i, not the value of i at the moment the lambda was defined. By the time you call them, the loop has finished and i is 2. All three closures see the same variable, and that variable holds the final loop value.
This is called late binding — Python resolves free variables (variables referenced in a function but not defined there) at call time, not at definition time. That is the opposite of default arguments, which are resolved at definition time. The asymmetry is a known quirk and the source of endless confusion.
The fix uses default arguments to force early binding:
funcs = []
for i in range(3):
funcs.append(lambda i=i: i)
[f() for f in funcs] # [0, 1, 2]
Here i=i is a default argument. The right-hand i is evaluated at definition time, capturing the current value of the loop variable. The left-hand i shadows it inside the lambda. It is a deliberate exploitation of the definition-time evaluation rule — using the “bug” as a feature.
What actually lives inside a function
All three of these surprises share a root: Python functions are objects, and they carry their context as data.
A function object has several relevant attributes. __defaults__ holds the tuple of default values for positional arguments, evaluated once when def runs. __kwdefaults__ holds keyword-only defaults. __closure__ holds the cell objects that closures use to share variables with enclosing scopes — and those cells hold references to variables, not snapshots of values.
Once you picture a function as an object with these slots, the behaviors stop being surprising:
- Mutable default: it is an object in
__defaults__, shared across calls, mutated in place. - Late-binding closure:
__closure__holds a cell referencing the variable, not a copy of its value. ison integers: two names might or might not point to the same cached object depending on CPython’s interning decisions.
The second diagram: definition time versus call time
Why the language works this way
It is tempting to call this a design mistake and move on. But the rules are internally consistent.
Default arguments are evaluated at def-time because that is the only moment the surrounding scope is available. If defaults were evaluated at call time, they would need access to the enclosing namespace, which might no longer exist. The trade-off was predictability in the common case (immutable defaults) at the cost of surprise in the less common case (mutable defaults).
Closures bind to variables rather than values because that is what makes them genuinely useful. A closure that captures a variable can reflect mutations the enclosing scope makes after the closure is created — which is exactly what you want when building a counter or a stateful callback. The late-binding loop trap is a misapplication of a feature, not a flaw in the feature itself.
Understanding this shifts how you read Python. You stop asking “why does Python do this strange thing” and start asking “what is the evaluation model, and what does it imply here.” The mutable default is just the most visible symptom of that model — visible because it ships bugs, invisible because it never raises an exception.
Three rules that prevent all of this
Defaults in Python are not fresh per call. Close over variables, not values. And is means identity, not equality.
Write those three sentences on a notecard. Put it where you type. They prevent every bug described here, and they make you a slightly better reader of other people’s code — because you will immediately spot the items=[] in their function signature and know, before running a single line, exactly what will go wrong.
That is the thing about footguns: they are always obvious in retrospect. The mutable default looks harmless. It reads like intent. It even works correctly for the first call. The damage accumulates silently, which is the worst kind, and understanding the mechanism is the only reliable protection.