I stared at my screen for 20 minutes, convinced Python was broken.
My function was supposed to return a fresh list every time. Instead, it was accumulating items like it had a memory. The third call somehow contained results from the first two.
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("apple")) # ['apple'] ✓
print(add_item("banana")) # ['apple', 'banana'] ???
print(add_item("cherry")) # ['apple', 'banana', 'cherry'] !!!
I expected ['banana']. I got ['apple', 'banana'].
This is Python's mutable default argument bug — and every Python developer gets bitten by it exactly once.
The Mental Model That's Lying to You
When you see items=[], your brain reads: "If no list is provided, create an empty one."
That's wrong.
What Python actually does: "Create one empty list **right now, when the function is defined, and reuse that same object for every call."
The default value isn't "an empty list" — it's a reference to one specific list object that was created when Python first read your function.
Let me prove it:
def add_item(item, items=[]):
print(f"List id: {id(items)}")
items.append(item)
return items
add_item("a") # List id: 4399504832
add_item("b") # List id: 4399504832 ← Same object!
add_item("c") # List id: 4399504832 ← Still same!
Every call uses the exact same list object. When you append to it, that change persists.
The Fix: Use None as a Sentinel
The solution is elegant once you understand the problem:
def add_item(item, items=None):
if items is None:
items = [] # Create a NEW list each call
items.append(item)
return items
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['banana'] ✓
Now each call that doesn't provide items gets a fresh list, created at call time, not definition time.
This pattern should become automatic for any mutable default:
# Lists
def process(items=None):
if items is None:
items = []
...
# Dictionaries
def merge(config=None):
if config is None:
config = {}
...
# Sets
def collect(seen=None):
if seen is None:
seen = set()
...
Why This Actually Matters
This isn't just a gotcha for interviews. It causes real bugs:
- Caching gone wrong: Your memoization function "remembers" too much
- API handlers accumulating state: Each request sees data from previous requests
- Test pollution: Tests pass individually but fail when run together
I've seen this bug in production code from senior engineers. It's subtle because the function works perfectly the first time — the bug only appears on subsequent calls.
The Deeper Lesson
Python's object model is consistent: variables are names pointing to objects. When you write items=[], you're creating an object and storing a reference to it. That reference is evaluated once — when the def statement runs.
Understanding this doesn't just help you avoid one bug. It unlocks how Python actually works under the hood.
This article is adapted from my upcoming book, **Zero to AI Engineer: Python Foundations.
I share excerpts like this on Substack — follow along for more!
Top comments (4)
Coming from another language, this is not normal behavior.
In other languages the default value, mutable or not, is not shared between function calls.
You called it the mutable default argument bug, but it is a feature that only affects mutable defaults.
You are spot on! This behavior is indeed a deliberate feature of Python's execution model, not a loose screw in the interpreter.
As you noted, many languages (like JS or C++) evaluate defaults at call time. Python evaluates them once at definition time.
I call it a "bug" here mostly because it's the source of bugs for so many developers. It's a classic case where the "feature" violates the intuition we bring from other languages. Thanks for highlighting that distinction!
Great breakdown of a subtle issue. What makes this 'bug' particularly dangerous isn't just the logic error, but how it pollutes state across what should be isolated calls. In a test suite, this becomes a nightmare because your tests pass in isolation but fail when run in a different order. Defensive programming with None isn't just about avoiding a quirk; it’s about ensuring our functions are truly pure and side-effect-free.
Thanks! You've hit on something I didn't emphasize enough — the test pollution angle is arguably the most insidious manifestation of this bug.
When tests pass in isolation but fail together, developers start questioning their test framework, their CI setup, even the test ordering algorithm — everywhere except a mutable default hiding in some utility function three layers deep.
And you're right that the None pattern isn't just defensive coding for its own sake. It's really about function purity — ensuring that calling f(x) with the same x always produces the same result, with no hidden state bleeding across invocations.
The broader lesson is that any time a function "remembers" something it shouldn't, look for objects created at definition time rather than call time. Default arguments are just the most common offender.
Appreciate you adding this perspective!