Default Parameters and the Mutable Default Argument Trap
Reading time: ~14 minutes | Level: Foundation → Engineering
This is one of Python's most notorious bugs. It has surprised every Python developer at least once:
def add_item(item, result=[]):
result.append(item)
return result
print(add_item("a")) # ["a"] ✓ expected
print(add_item("b")) # ["a", "b"] ✗ NOT expected!
print(add_item("c")) # ["a", "b", "c"] ✗ growing forever!
The list keeps growing across calls. You defined a fresh [] - how is state leaking between calls?
This is not a bug in Python. It is Python behaving exactly as designed, and once you understand why, you will never make this mistake again - and you will spot it immediately in code reviews.
What You Will Learn
- Why Python evaluates default values exactly once, at function definition time
- What happens to that default value between function calls
- How to prove this with
id()and__defaults__ - Why any mutable type as a default (list, dict, set, custom object) is a trap
- The correct fix: using
Noneas a sentinel value - Why
def f(x=datetime.now())is also a bug - The function attribute pattern as an alternative
- How this interacts with the
defstatement's runtime behavior
Prerequisites
- Python function definition syntax (
def, parameters,return) - Understanding that
defis a runtime statement - Understanding of mutable vs immutable types
- Basic Python data types (list, dict, None)
Mental Model: Defaults Are Born Once
When Python executes the def statement:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def add_item(item, result=[]): ← [] evaluated HERE, ONCE
result.append(item)
return result
Python creates:
┌─────────────────────────────────────────────┐
│ function object: add_item │
│ __defaults__: ([ ],) ← one list object │
│ id: 0xABCD │
└─────────────────────────────────────────────┘
│
▼ That SAME list object is reused on every call
Call 1: add_item("a") → result = [] → append → ["a"]
Call 2: add_item("b") → result = ["a"] → append → ["a","b"]
Call 3: add_item("c") → result = ["a","b"] → append → ["a","b","c"]
THE FIX - None sentinel pattern:
def add_item(item, result=None): ← None is immutable, cannot be mutated
if result is None:
result = [] ← fresh list created on EVERY call
result.append(item)
return result
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Watch: Python Default Arguments Deep Dive
Part 1 - How Defaults Are Evaluated: The def Statement
Recall: def is a runtime statement. When Python executes a def statement, it:
- Compiles the function body to a code object
- Evaluates all default value expressions - right then, once
- Creates a function object that stores those evaluated defaults
- Binds the function name to that object
import time
def make_timestamp(ts=time.time()): # time.time() called ONCE, at definition
return ts
time.sleep(2)
print(make_timestamp()) # Returns the timestamp from when the def ran, not now
print(make_timestamp()) # Same value every call - the default is fixed
You can inspect the stored defaults directly:
def greet(name="World", punctuation="!"):
return f"Hello, {name}{punctuation}"
print(greet.__defaults__) # ('World', '!')
print(type(greet.__defaults__)) # <class 'tuple'>
Defaults are stored in a tuple on the function object. That tuple is created once when def executes.
Part 2 - The Mutable Default Trap in Detail
When the default is a mutable object (list, dict, set), that single object is shared across all calls that use the default.
def add_item(item, result=[]):
result.append(item)
return result
# Inspecting the default object's identity
print(id(add_item.__defaults__[0])) # e.g., 140234567890
r1 = add_item("a")
print(id(r1)) # 140234567890 ← same object as the default!
r2 = add_item("b")
print(id(r2)) # 140234567890 ← still the same object!
print(r1 is r2) # True - they are all the same list
print(add_item.__defaults__) # (['a', 'b'],) ← the default has been mutated
Every call that uses the default is referencing - and mutating - the same list object.
The dict version (equally common bug)
def update_config(key, value, config={}):
config[key] = value
return config
c1 = update_config("debug", True)
c2 = update_config("verbose", False)
print(c1) # {"debug": True, "verbose": False} ← both calls modified the same dict
print(c2) # {"debug": True, "verbose": False}
print(c1 is c2) # True - same object
Part 3 - The Correct Fix: The None Sentinel Pattern
The standard Python idiom is to use None as the default and create the mutable object inside the function body:
def add_item(item, result=None):
if result is None:
result = [] # fresh list on every call that doesn't pass result
result.append(item)
return result
print(add_item("a")) # ["a"]
print(add_item("b")) # ["b"] - fresh list!
print(add_item("c", result=["x"])) # ["x", "c"] - uses the passed list
Why None? Because None is:
- Immutable - it cannot be mutated between calls
- Falsy - easy to check with
if result is None: - A singleton -
is Noneis the correct and idiomatic check - Clearly communicates "no value provided" to readers of the code
:::warning Always use is None not == None
Use if result is None: not if result == None:. The is check is correct because None is a singleton. The == check works but is non-idiomatic and may trigger linting warnings.
:::
# The None sentinel pattern in real functions
def connect(host, port=5432, options=None):
if options is None:
options = {}
options.setdefault("timeout", 30)
options.setdefault("retry", 3)
return f"Connecting to {host}:{port} with {options}"
print(connect("localhost"))
# Connecting to localhost:5432 with {'timeout': 30, 'retry': 3}
print(connect("prod-db", options={"timeout": 60}))
# Connecting to prod-db:5432 with {'timeout': 60, 'retry': 3}
Part 4 - When Defaults Are Evaluated: Tricky Cases
The datetime.now() default bug
from datetime import datetime
# BUG: datetime.now() is evaluated once when the module loads
def log_event(message, timestamp=datetime.now()):
print(f"[{timestamp}] {message}")
log_event("start") # [2024-01-01 10:00:00] start
# ... time passes ...
log_event("end") # [2024-01-01 10:00:00] end ← same timestamp!
# Fix:
def log_event(message, timestamp=None):
if timestamp is None:
timestamp = datetime.now() # evaluated at call time
print(f"[{timestamp}] {message}")
Class instances as defaults
class Config:
def __init__(self):
self.settings = {}
# BUG: one Config object shared across all calls
def run_job(name, config=Config()):
config.settings[name] = True
return config
j1 = run_job("job1")
j2 = run_job("job2")
print(j1.settings) # {"job1": True, "job2": True} ← shared!
# Fix:
def run_job(name, config=None):
if config is None:
config = Config()
config.settings[name] = True
return config
Immutable defaults are safe
# These are all safe - immutable defaults cannot be mutated:
def f(x=0): pass # int
def f(x="hello"): pass # str
def f(x=(1, 2)): pass # tuple
def f(x=True): pass # bool
def f(x=None): pass # None
def f(x=frozenset()): pass # frozenset
Part 5 - Inspecting Defaults and the Intentional Mutable Cache
You can inspect the defaults on a function object:
def greet(name="World"):
return f"Hello, {name}"
print(greet.__defaults__) # ('World',)
The deliberate mutable default (rare, intentional)
Sometimes a shared mutable default is intentional - used as a persistent cache:
def fibonacci(n, _cache={0: 0, 1: 1}):
"""Compute nth Fibonacci with memoization via mutable default."""
if n not in _cache:
_cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
return _cache[n]
print(fibonacci(10)) # 55
print(fibonacci(50)) # 12586269025 (fast - cache persists across calls)
The leading underscore in _cache signals "this is an implementation detail, not a normal argument." This pattern is valid but unusual - functools.lru_cache is the modern preferred approach.
AI/ML Real-World Connection
Default parameter bugs are especially common in ML training code.
# BUG: shared history list across training runs
def train_epoch(model, data, history=[]):
loss = compute_loss(model, data)
history.append(loss)
return history
losses_run1 = train_epoch(model, data) # [0.8]
losses_run2 = train_epoch(model, data) # [0.8, 0.7] ← contains run1 data!
# Fix:
def train_epoch(model, data, history=None):
if history is None:
history = []
loss = compute_loss(model, data)
history.append(loss)
return history
# BUG: shared config dict accumulating settings across experiments
def build_model(layers=3, config={}):
config["layers"] = layers
config["activation"] = "relu"
return config
exp1 = build_model(3)
exp2 = build_model(6)
print(exp1) # {"layers": 6, "activation": "relu"} ← overwritten by exp2!
# Fix:
def build_model(layers=3, config=None):
if config is None:
config = {}
config["layers"] = layers
config["activation"] = "relu"
return config
Common Mistakes
Mistake 1: Using a list as a default
# BUG
def collect(item, results=[]):
results.append(item)
return results
# Fix
def collect(item, results=None):
if results is None:
results = []
results.append(item)
return results
Mistake 2: Using == None instead of is None
# Non-idiomatic
def process(data=None):
if data == None: # works but wrong idiom
data = []
# Correct
def process(data=None):
if data is None: # idiomatic: None is a singleton
data = []
Mistake 3: Using a default with side effects or time-dependent values
import random
# BUG: random.randint called once at definition
def generate_id(prefix="user", id=random.randint(1000, 9999)):
return f"{prefix}_{id}"
print(generate_id()) # user_4523
print(generate_id()) # user_4523 ← same ID every time!
# Fix:
def generate_id(prefix="user", id=None):
if id is None:
id = random.randint(1000, 9999)
return f"{prefix}_{id}"
Interview Questions
Q1: What is the mutable default argument trap in Python?
Answer: When a mutable object (like a list or dict) is used as a default parameter value, Python evaluates it once at function definition time. The same object is shared across all calls that use the default. If the function mutates it, those mutations persist across calls - leading to unexpected state accumulation.
Q2: When are default parameter values evaluated in Python?
Answer: Default values are evaluated once, when the def statement executes. They are stored in the function object's __defaults__ tuple. They are not re-evaluated on each function call. This is why mutable defaults are dangerous - and why datetime.now() as a default always gives the same timestamp.
Q3: What is the None sentinel pattern and why is None the right choice?
Answer: The pattern replaces a mutable default with None, then creates the mutable object inside the function body:
def f(data=None):
if data is None:
data = []
None is chosen because it is immutable, a singleton (fast is check), and clearly communicates "no value provided."
Q4: Are there cases where a mutable default is intentional?
Answer: Yes. A mutable default dict can serve as a persistent cache across calls (memoization). The convention is to name it with a leading underscore to signal it is an implementation detail. However, functools.lru_cache is the preferred modern approach.
Q5: How do you inspect the default values of a function?
Answer: Through the __defaults__ attribute, which returns a tuple of positional defaults, and __kwdefaults__ for keyword-only defaults:
def f(x=1, y="hello"):
pass
print(f.__defaults__) # (1, 'hello')
Quick Reference Cheatsheet
| Default Type | Safe? | Reason |
|---|---|---|
int (e.g., x=0) | ✅ Safe | Immutable |
str (e.g., s="") | ✅ Safe | Immutable |
None | ✅ Safe | Immutable singleton |
tuple | ✅ Safe | Immutable |
bool | ✅ Safe | Immutable |
list (e.g., x=[]) | ❌ Trap | Mutable, shared across calls |
dict (e.g., d={}) | ❌ Trap | Mutable, shared across calls |
set (e.g., s=set()) | ❌ Trap | Mutable, shared across calls |
datetime.now() | ❌ Trap | Evaluated once at definition |
| Class instance | ❌ Trap | Mutable, shared across calls |
Graded Practice Challenges
Level 1 - Predict the Output
def f(x, data=[]):
data.append(x)
return data
a = f(1)
b = f(2)
c = f(3, [])
print(a)
print(b)
print(c)
print(a is b)
Show Answer
Output:
[1, 2]
[1, 2]
[3]
True
f(1) and f(2) both use the same default list - a and b are the same object. f(3, []) passes a fresh list explicitly, so c is a separate object.
Level 2 - Debug the Code
class Event:
def __init__(self, name, tags=[]):
self.name = name
self.tags = tags
def add_tag(self, tag):
self.tags.append(tag)
e1 = Event("conference")
e2 = Event("meetup")
e1.add_tag("python")
e2.add_tag("ai")
print(e1.tags)
print(e2.tags)
What is the output, what is the bug, and how do you fix it?
Show Answer
Output:
['python', 'ai']
['python', 'ai']
Bug: The tags=[] default is evaluated once when the class is defined. All instances that do not pass an explicit tags share the same list object.
Fix:
class Event:
def __init__(self, name, tags=None):
self.name = name
self.tags = tags if tags is not None else []
Level 3 - Design Challenge
Write a memoize decorator using a mutable default dict as an intentional cache. Then show the same using functools.lru_cache. Compare the two approaches.
Show Reference Solution
# Approach 1: Manual memoization via mutable default
def fibonacci(n, _cache={0: 0, 1: 1}):
"""Fibonacci with manual memoization via intentional mutable default."""
if n not in _cache:
_cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
return _cache[n]
print(fibonacci(10)) # 55
print(fibonacci(50)) # 12586269025
# Approach 2: functools.lru_cache (preferred modern approach)
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci_cached(n):
if n < 2:
return n
return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)
print(fibonacci_cached(10)) # 55
print(fibonacci_cached.cache_info()) # CacheInfo(hits=..., misses=..., ...)
fibonacci_cached.cache_clear() # Can reset the cache - not possible with mutable default
# Key differences:
# - lru_cache is explicit, auditable, and resettable
# - mutable default is clever but less visible
# - Use lru_cache in production
Key Takeaways
- Default values are evaluated once when the
defstatement executes - not on every call - A mutable default (list, dict, set) is shared across all calls that use the default - mutations accumulate
- The fix is the None sentinel pattern: use
Noneas the default, create the mutable object inside the function body datetime.now(), random numbers, and class instances as defaults are also traps- Immutable defaults (int, str, tuple, None, bool) are always safe
- Inspect stored defaults via
function.__defaults__(a tuple) - Intentional mutable defaults for caching are valid but unusual - prefer
functools.lru_cache - Always use
is None(not== None) when checking the sentinel
