datarekha
Patterns June 2, 2026

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.

8 min read · by datarekha · pythondebugginggotchasfunctionsclosures

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

Mutable default: one shared list vs. fresh list per callBROKEN — def append_item(item, items=[])FIXED — items=None, build insideappend_item.defaults[ ] ← born here, at def-timecall 1call 2call 3SAME LIST OBJECT[“a”, “b”, “c”] after 3 calls[“a”][“a”,“b”][“a”,“b”,“c”]returned value grows with every callcall 1call 2call 3[ ][ ][ ]each call builds its own list[“a”] / [“b”] / [“c”]
Left: three calls share one list object; it grows forever. Right: each call gets its own fresh list because the default is None and the list is created inside the body.

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.
  • is on 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

Evaluation timing: def-time vs. call-timetimeline →def runsfunction calledDEFAULT ARGS evaluated hereitems=[] → list object born, stored in defaultsCLOSURE VARS resolved herelambda reads i now → whatever i holds at call timeConsequenceMutable default accumulatesstate silently across callsConsequenceAll loop lambdas see thefinal loop variable valueFix: use None sentinel, build inside bodyFix: capture with lambda i=i default
Default arguments are fixed at def-time; closure variables are looked up at call-time. Both rules are consistent — but they pull in opposite directions, which is why the bugs feel contradictory.

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.

Skip to content