Skip to main content

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 None as 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 def statement's runtime behavior

Prerequisites

  • Python function definition syntax (def, parameters, return)
  • Understanding that def is 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:

  1. Compiles the function body to a code object
  2. Evaluates all default value expressions - right then, once
  3. Creates a function object that stores those evaluated defaults
  4. 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 None is 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 TypeSafe?Reason
int (e.g., x=0)✅ SafeImmutable
str (e.g., s="")✅ SafeImmutable
None✅ SafeImmutable singleton
tuple✅ SafeImmutable
bool✅ SafeImmutable
list (e.g., x=[])❌ TrapMutable, shared across calls
dict (e.g., d={})❌ TrapMutable, shared across calls
set (e.g., s=set())❌ TrapMutable, shared across calls
datetime.now()❌ TrapEvaluated once at definition
Class instance❌ TrapMutable, 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 def statement 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 None as 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
© 2026 EngineersOfAI. All rights reserved.