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 (0)