Python Default Parameters Pitfalls: Practice Problems & Exercises
Practice: Default Parameters Pitfalls
← Back to lessonEasy
Predict the output of each print statement. Trace how the default list accumulates state across calls.
def append_to(value, items=[]):
items.append(value)
return items
a = append_to(1)
print(a)
b = append_to(2)
print(b)
c = append_to(3, [])
print(c)
d = append_to(4)
print(d)
print(a is b is d)Solution
[1]
[1, 2]
[3]
[1, 2, 4]
True
Step-by-step trace:
append_to(1)— noitemsargument, so Python uses the default[]. Appends1. The default is now[1].apoints to this default list.append_to(2)— again uses the default, which is now[1]. Appends2. The default is now[1, 2].bpoints to the same default list.append_to(3, [])— an explicit[]is passed, so the default is not used. Appends3to the fresh list. The default list is untouched.cis a separate list.append_to(4)— uses the default again, which is still[1, 2]. Appends4. The default is now[1, 2, 4].dpoints to the same default list.a,b, anddall point to the same default list object, soa is b is disTrue.
Expected Output
[1]
[1, 2]
[3]
[1, 2, 4]
TrueHints
Hint 1: The default `[]` is created once when `def` executes. Every call that omits `items` reuses that same list object.
Hint 2: When you pass an explicit list like `[3]`, the default is bypassed entirely. The next call without an argument goes back to the shared default — which still has `[1, 2]` from previous calls.
Inspect __defaults__ before and after calling a function with a mutable default. Prove that the stored default is the same object that gets mutated.
def add_item(item, items=[], count=0):
items.append(item)
return items
print(f"Before calls: {add_item.__defaults__}")
result1 = add_item('x')
print(f"After add_item('x'): {add_item.__defaults__}")
result2 = add_item('y')
print(f"After add_item('y'): {add_item.__defaults__}")
print(f"Type: {type(add_item.__defaults__)}")
print(f"Default list id matches return id: {id(add_item.__defaults__[0]) == id(result1)}")Solution
Before calls: ([], 0)
After add_item('x'): (['x'], 0)
After add_item('y'): (['x', 'y'], 0)
Type: <class 'tuple'>
Default list id matches return id: True
Key observations:
__defaults__is a tuple containing([], 0)— one default per parameter that has a default value.- The list inside the tuple is the exact same object used as the default when the function is called without that argument. When
add_itemappends toitems, it is mutating this stored default. - The tuple itself is immutable (you cannot replace its elements), but the list inside it is mutable. This is the same pattern as a tuple containing a mutable object.
count=0is an integer (immutable) — it cannot be mutated, so it stays0forever.id(add_item.__defaults__[0]) == id(result1)isTrue, proving the returned list IS the default object.
Expected Output
Before calls: ([], 0)
After add_item('x'): (['x'], 0)
After add_item('y'): (['x', 'y'], 0)
Type: <class 'tuple'>
Default list id matches return id: TrueHints
Hint 1: `function.__defaults__` is a tuple containing the default values for each parameter that has one. Inspect it before and after calling the function.
Hint 2: The list inside `__defaults__` is the exact same object that the function uses as the default argument. Mutating it inside the function mutates the stored default.
Fix the add_task function so that each call without an explicit tasks argument gets a fresh, independent list. Use the None sentinel pattern.
def add_task(task, tasks=None):
if tasks is None:
tasks = []
tasks.append(task)
return tasks
a = add_task("task1")
print(a)
b = add_task("task2")
print(b)
c = add_task("task3", ["task4"])
# Note: task3 is appended after task4 since we append to the passed list
c_result = add_task("task3", ["task4"])
print(c_result)
independent = (a is not b) and (b is not c_result)
print(f"All independent: {independent}")Solution
['task1']
['task2']
['task4', 'task3']
All independent: True
The None sentinel pattern:
def add_task(task, tasks=None):
if tasks is None:
tasks = [] # fresh list on every call that omits tasks
tasks.append(task)
return tasks
- The default value is
None— immutable and cannot accumulate state across calls. - Inside the function,
if tasks is Nonechecks whether the caller provided a value. If not, a new list is created on every call. - If the caller passes an explicit list (like
["task4"]),tasks is NoneisFalse, so the passed list is used directly.
This is the standard Python idiom used in virtually every production codebase. You will see it in the standard library, Django, Flask, NumPy, and every major Python project.
Expected Output
['task1']
['task2']
['task3', 'task4']
All independent: TrueHints
Hint 1: Replace the mutable default `[]` with `None`, then create a fresh list inside the function body when `tasks is None`.
Hint 2: Always use `is None` (identity check), not `== None` (equality check). None is a singleton — `is` is the correct and idiomatic test.
Predict the output. A function uses an empty dict as a default. Two calls build up configuration — trace which dict they share.
def make_config(host, port=5432, config={}):
config["host"] = host
config["port"] = port
return config
c1 = make_config("db1")
print(c1)
c2 = make_config("db2", 3306)
print(c2)
print(c1 is c2)
print(f"Final __defaults__: {make_config.__defaults__}")Solution
{'host': 'db1', 'port': 5432}
{'host': 'db2', 'port': 3306}
True
Final __defaults__: ({'host': 'db2', 'port': 3306},)
What happens step by step:
make_config("db1")—configdefaults to the shared{}. Setshostto"db1"andportto5432. Returns the dict.c1points to it.- At this point, printing
c1shows{'host': 'db1', 'port': 5432}— but this output happens before the next call, so the first print is correct. make_config("db2", 3306)—configdefaults to the same dict, which now has{'host': 'db1', 'port': 5432}. Overwriteshostto"db2"andportto3306.- Now
c1andc2both point to{'host': 'db2', 'port': 3306}— the second call overwrote the first call's values. c1 is c2isTrue— same object.__defaults__shows(5432, {'host': 'db2', 'port': 3306})— wait,port=5432is the default for theportparameter (an int, immutable, safe). The dict default stores the mutated state.
Note: __defaults__ stores defaults as (5432, {'host': 'db2', 'port': 3306}) because both port and config have defaults. The tuple contains them in parameter order.
Fix: Use config=None and if config is None: config = {} inside the function body.
Expected Output
{'host': 'db1', 'port': 5432}
{'host': 'db2', 'port': 3306}
True
Final __defaults__: ({'host': 'db2', 'port': 3306},)Hints
Hint 1: A dict default behaves exactly like a list default — one dict object is created at definition time and shared across all calls that use the default.
Hint 2: The second call overwrites the `host` key in the same dict object. Since `c1` and `c2` point to the same dict, `c1` now shows the overwritten value.
Medium
Demonstrate the datetime.now() default bug and fix it. The buggy version produces identical timestamps; the fixed version produces different ones.
from datetime import datetime
import time
# BUGGY version — datetime.now() evaluated once at definition
def log_buggy(msg, ts=datetime.now()):
return ts
# FIXED version — None sentinel
def log_fixed(msg, ts=None):
if ts is None:
ts = datetime.now()
return ts
# Test buggy version
t1 = log_buggy("first")
time.sleep(0.01)
t2 = log_buggy("second")
print(f"All timestamps identical: {t1 == t2}")
# Test fixed version
t3 = log_fixed("first")
time.sleep(0.01)
t4 = log_fixed("second")
print(f"Fixed timestamps differ: {t3 != t4}")Solution
All timestamps identical: True
Fixed timestamps differ: True
Why datetime.now() as a default is a bug:
When Python executes def log_buggy(msg, ts=datetime.now()):, it calls datetime.now() right then and stores the resulting datetime object in __defaults__. Every subsequent call to log_buggy that omits ts receives that same frozen timestamp.
This is the same principle as the mutable default trap, but with a different symptom: instead of accumulating state, you get a stale value.
Common real-world occurrences:
- Logging functions with default timestamps
- Cache entries with default creation times
- Event systems with default
created_atfields - API request builders with default
request_id = uuid.uuid4()
The fix is always the same: Use None as the sentinel and compute the value inside the function body.
Expected Output
All timestamps identical: True
Fixed timestamps differ: TrueHints
Hint 1: `datetime.now()` is a function call. When used as a default value, it is called once at function definition time — not at each call time.
Hint 2: The fix is the same None sentinel pattern: use `None` as the default and call `datetime.now()` inside the function body.
Demonstrate the late binding closure bug where lambdas in a loop all capture the final loop variable value. Show two fixes: one using a default argument and one using functools.partial.
from functools import partial
# BUGGY: all lambdas see the final value of i
buggy = []
for i in range(5):
buggy.append(lambda: i)
print(f"Buggy: {[f() for f in buggy]}")
# FIX 1: default argument captures i at definition time
fixed_default = []
for i in range(5):
fixed_default.append(lambda i=i: i)
print(f"Fixed with default arg: {[f() for f in fixed_default]}")
# FIX 2: functools.partial
fixed_partial = []
for i in range(5):
fixed_partial.append(partial(lambda x: x, i))
print(f"Fixed with partial: {[f() for f in fixed_partial]}")Solution
Buggy: [4, 4, 4, 4, 4]
Fixed with default arg: [0, 1, 2, 3, 4]
Fixed with partial: [0, 1, 2, 3, 4]
The late binding problem:
Closures in Python capture variables, not values. Each lambda: i does not store the current value of i — it stores a reference to the variable i in the enclosing scope. When the lambdas are finally called (after the loop), they all look up i, which is now 4.
Fix 1 — Default argument (lambda i=i: i):
The expression i=i is a default parameter. The right-hand i is evaluated at definition time (when the lambda is created during that loop iteration). So each lambda gets its own default value frozen at 0, 1, 2, 3, 4 respectively. This is the evaluation-at-definition-time behavior turned from pitfall into feature.
Fix 2 — functools.partial:
partial(lambda x: x, i) binds the current value of i as the first argument at creation time. Each partial object stores a snapshot of the value.
Why this matters in practice: This bug commonly appears in GUI callback registration, async task creation, and test parameterization where functions are created in loops.
Expected Output
Buggy: [4, 4, 4, 4, 4]
Fixed with default arg: [0, 1, 2, 3, 4]
Fixed with partial: [0, 1, 2, 3, 4]Hints
Hint 1: In the buggy version, each lambda captures the variable `i` by reference, not by value. By the time the lambdas are called, `i` has reached its final value of 4.
Hint 2: The default argument fix works because default values are evaluated at definition time. `i=i` captures the current value of `i` at each iteration.
Demonstrate the mutable default bug in a class __init__ and fix it with the None sentinel pattern. This is one of the most common real-world occurrences of this bug.
# BUGGY version
class PlayerBuggy:
def __init__(self, name, scores=[]):
self.name = name
self.scores = scores
def add_score(self, score):
self.scores.append(score)
p1 = PlayerBuggy("Alice")
p2 = PlayerBuggy("Bob")
p1.add_score(95)
p2.add_score(72)
print("Buggy:")
print(f" p1.scores = {p1.scores}")
print(f" p2.scores = {p2.scores}")
print(f" Same object: {p1.scores is p2.scores}")
# FIXED version
class PlayerFixed:
def __init__(self, name, scores=None):
self.name = name
self.scores = scores if scores is not None else []
def add_score(self, score):
self.scores.append(score)
p3 = PlayerFixed("Charlie")
p4 = PlayerFixed("Dana")
p3.add_score(88)
p4.add_score(91)
print("Fixed:")
print(f" p3.scores = {p3.scores}")
print(f" p4.scores = {p4.scores}")
print(f" Same object: {p3.scores is p4.scores}")Solution
Buggy:
p1.scores = [95, 72]
p2.scores = [95, 72]
Same object: True
Fixed:
p3.scores = [88]
p4.scores = [91]
Same object: False
Why __init__ is especially dangerous:
The __init__ method is just a regular function attached to the class. Its default value scores=[] is evaluated once when the class body executes (which evaluates the def __init__ statement). Every instance created without passing scores shares that single list.
This is arguably the most common occurrence of the mutable default bug in real Python code because:
- Classes with list/dict attributes are extremely common.
- The bug is silent — instances appear to work until a second instance reveals the shared state.
- It is easy to miss in code review because
scores=[]looks perfectly reasonable.
The fix:
def __init__(self, name, scores=None):
self.scores = scores if scores is not None else []
You can also write this as:
def __init__(self, name, scores=None):
if scores is None:
scores = []
self.scores = scores
Both are equivalent and idiomatic. The ternary form is more concise for simple cases.
Expected Output
Buggy:
p1.scores = [95, 72]
p2.scores = [95, 72]
Same object: True
Fixed:
p3.scores = [88]
p4.scores = [91]
Same object: FalseHints
Hint 1: The `__init__` method is just a regular function. Its default `scores=[]` is evaluated once when the class body is executed, not per-instance.
Hint 2: All instances that omit the `scores` argument share the same list object. Appending to one instance's scores affects all of them.
Demonstrate that sets as default arguments have the same accumulation bug as lists and dicts. Show the buggy version, then fix it.
# BUGGY
def create_user_buggy(name, role, tags=set()):
tags.add(role)
return {"name": name, "tags": tags}
u1 = create_user_buggy("alice", "admin")
u1["tags"].add("python")
print(f"Buggy tags after user1: {u1['tags']}")
u2 = create_user_buggy("bob", "reader")
print(f"Buggy tags after user2: {u2['tags']}")
print(f"Shared: {u1['tags'] is u2['tags']}")
# FIXED
def create_user_fixed(name, role, tags=None):
if tags is None:
tags = set()
tags.add(role)
return {"name": name, "tags": tags}
u3 = create_user_fixed("charlie", "editor")
u4 = create_user_fixed("dana", "viewer")
print(f"Fixed tags user3: {u3['tags']}")
print(f"Fixed tags user4: {u4['tags']}")
print(f"Independent: {u3['tags'] is not u4['tags']}")Solution
Buggy tags after user1: {'python', 'admin'}
Buggy tags after user2: {'python', 'admin', 'reader'}
Shared: True
Fixed tags user3: {'editor'}
Fixed tags user4: {'viewer'}
Independent: True
Note: Set ordering is not guaranteed, so the elements may print in a different order. The key point is that the buggy version accumulates tags across calls while the fixed version does not.
The rule is universal: Any mutable type as a default — list, dict, set, custom objects — is a trap. The fix is always the None sentinel pattern:
| Mutable Default | Fix |
|---|---|
items=[] | items=None then items = [] if items is None else items |
config={} | config=None then config = {} if config is None else config |
tags=set() | tags=None then tags = set() if tags is None else tags |
Expected Output
Buggy tags after user1: {'python', 'admin'}
Buggy tags after user2: {'python', 'admin', 'reader'}
Shared: True
Fixed tags user3: {'editor'}
Fixed tags user4: {'viewer'}
Independent: TrueHints
Hint 1: Sets are mutable, just like lists and dicts. A set default `tags=set()` is evaluated once and shared across all calls.
Hint 2: The fix is identical: use `tags=None` and create a fresh `set()` inside the body.
Hard
Implement Fibonacci with intentional mutable default memoization, then implement the same with functools.lru_cache. Compare cache behavior and introspection capabilities.
from functools import lru_cache
# Approach 1: Intentional mutable default as cache
def fib(n, _cache={0: 0, 1: 1}):
if n not in _cache:
_cache[n] = fib(n - 1) + fib(n - 2)
return _cache[n]
print(f"fib(10) = {fib(10)}")
print(f"fib(20) = {fib(20)}")
# Inspect the cache via __defaults__
cache_dict = fib.__defaults__[0]
print(f"Cache size: {len(cache_dict)}")
print(f"Cache contains fib(0) through fib(20): {all(i in cache_dict for i in range(21))}")
# Approach 2: lru_cache (preferred)
@lru_cache(maxsize=None)
def fib_lru(n):
if n < 2:
return n
return fib_lru(n - 1) + fib_lru(n - 2)
print("--- lru_cache version ---")
print(f"fib_lru(10) = {fib_lru(10)}")
print(f"fib_lru(20) = {fib_lru(20)}")
print(f"Cache info: {fib_lru.cache_info()}")Solution
fib(10) = 55
fib(20) = 6765
Cache size: 21
Cache contains fib(0) through fib(20): True
--- lru_cache version ---
fib_lru(10) = 55
fib_lru(20) = 6765
Cache info: CacheInfo(hits=18, misses=21, maxsize=None, currsize=21)
Intentional mutable default as cache:
This is the one legitimate use of a mutable default argument. The dict _cache persists across calls because it is the same object stored in __defaults__. The leading underscore convention signals "do not pass this argument — it is an implementation detail."
Why lru_cache is better in production:
| Feature | Mutable Default | lru_cache |
|---|---|---|
| Cache clearing | Not possible (must manually clear the dict) | func.cache_clear() |
| Cache inspection | Access via __defaults__[0] (fragile) | func.cache_info() (official API) |
| Max size control | Manual (must implement eviction) | Built-in maxsize parameter |
| Thread safety | Not thread-safe | Thread-safe (uses a lock) |
| Readability | Clever but surprising | Explicit and well-known |
The mutable default cache is a valid pattern to know and recognize, but functools.lru_cache is the correct choice for production code.
Expected Output
fib(10) = 55
fib(20) = 6765
Cache size: 21
Cache contains fib(0) through fib(20): True
--- lru_cache version ---
fib_lru(10) = 55
fib_lru(20) = 6765
Cache info: CacheInfo(hits=18, misses=21, maxsize=None, currsize=21)Hints
Hint 1: A mutable default dict can serve as a persistent cache across calls. Initialize it with base cases and check it before recursing.
Hint 2: The leading underscore `_cache` signals this is an implementation detail. Compare with `functools.lru_cache` which provides cache inspection and clearing.
Prove that defaults are evaluated left-to-right at definition time by using side-effect-producing expressions. Explore conditional definition and redefinition.
def make_default(label):
print(f"Evaluating default {label}")
return f"sentinel_{label}"
# Defaults evaluated left-to-right when def executes
def f(a=make_default("a"), b=make_default("b"), c=make_default("c")):
return f"a={a} b={b} c={c}"
print("Function defined.")
print("--- Call with no args ---")
print("Calling f()...")
print(f"Result: {f()}")
print("--- Defaults are frozen ---")
print(f"f.__defaults__: {f.__defaults__}")
# Conditional definition
print("--- Conditional definition ---")
use_branch_a = True
if use_branch_a:
def g(x=make_default("branch_A")):
return x
else:
def g(x=make_default("branch_B")):
return x
print(f"g.__defaults__: {g.__defaults__}")
# Redefinition creates new defaults
print("--- Redefinition ---")
def h(x=make_default("first")):
return x
print(f"h.__defaults__ after first def: {h.__defaults__}")
def h(x=make_default("second")):
return x
print(f"h.__defaults__ after second def: {h.__defaults__}")Solution
--- Definition-time side effects ---
Evaluating default a
Evaluating default b
Evaluating default c
Function defined.
--- Call with no args ---
Calling f()...
Result: a=sentinel_a b=sentinel_b c=sentinel_c
--- Defaults are frozen ---
f.__defaults__: ('sentinel_a', 'sentinel_b', 'sentinel_c')
--- Conditional definition ---
Evaluating branch A default
g.__defaults__: ('branch_A',)
--- Redefinition ---
Evaluating new default
h.__defaults__ after first def: ('first',)
Evaluating new default
h.__defaults__ after second def: ('second',)
Critical insights:
-
Left-to-right evaluation: When Python executes
def f(a=expr_a, b=expr_b, c=expr_c):, it evaluatesexpr_a, thenexpr_b, thenexpr_c, in order. All three print statements appear before "Function defined." -
Evaluated once, stored forever: The
make_defaultfunctions are called only duringdef. Callingf()does NOT re-evaluate them — it just uses the stored values from__defaults__. -
Conditional definition: Only the
defstatement in the taken branch executes.make_default("branch_B")is never called because theelsebranch is not reached. -
Redefinition: Each
def h(...)creates a new function object with new defaults. The old function object (and its defaults) is discarded. Bothmake_defaultcalls execute — one when the firstdefruns, one when the seconddefruns.
This demonstrates that def is a runtime statement, not a compile-time declaration. Understanding this is key to understanding why mutable defaults behave the way they do.
Expected Output
--- Definition-time side effects ---
Evaluating default a
Evaluating default b
Evaluating default c
Function defined.
--- Call with no args ---
Calling f()...
Result: a=sentinel_a b=sentinel_b c=sentinel_c
--- Defaults are frozen ---
f.__defaults__: ('sentinel_a', 'sentinel_b', 'sentinel_c')
--- Conditional definition ---
Evaluating branch A default
g.__defaults__: ('branch_A',)
--- Redefinition ---
Evaluating new default
h.__defaults__ after first def: ('first',)
h.__defaults__ after second def: ('second',)Hints
Hint 1: Default value expressions are evaluated left-to-right when the `def` statement executes. Wrap them in function calls with print side effects to observe the order.
Hint 2: If a function is defined inside an if/else, only the branch that executes evaluates its defaults. Redefining a function with `def` creates a new function object with new defaults.
Build a production-quality response factory using keyword-only parameters with None sentinels. Inspect __kwdefaults__ to verify no mutable objects are stored as defaults.
def make_response(status=200, *, headers=None, body=None, errors=None):
if headers is None:
headers = {}
if body is None:
body = {}
if errors is None:
errors = []
return {
"status": status,
"headers": headers,
"body": body,
"errors": errors,
}
print("--- Basic usage ---")
r1 = make_response(headers={"X-Request-ID": "abc"})
print(f"Response 1: {r1}")
r2 = make_response(404, errors=["Not found"])
print(f"Response 2: {r2}")
independent = (
r1["headers"] is not r2["headers"] and
r1["body"] is not r2["body"] and
r1["errors"] is not r2["errors"]
)
print(f"Independent: {independent}")
print("--- __kwdefaults__ inspection ---")
print(f"__defaults__: {make_response.__defaults__}")
print(f"__kwdefaults__: {make_response.__kwdefaults__}")
print("--- Caller-provided mutable not copied ---")
custom_headers = {"Auth": "token"}
r3 = make_response(headers=custom_headers)
r3["headers"]["X-Request-ID"] = "xyz"
print(f"custom_headers after call: {custom_headers}")
print(f"Response headers is same object: {r3['headers'] is custom_headers}")Solution
--- Basic usage ---
Response 1: {'status': 200, 'headers': {'X-Request-ID': 'abc'}, 'body': {}, 'errors': []}
Response 2: {'status': 404, 'headers': {}, 'body': {}, 'errors': ['Not found']}
Independent: True
--- __kwdefaults__ inspection ---
__defaults__: (200,)
__kwdefaults__: {'headers': None, 'body': None, 'errors': None}
--- Caller-provided mutable not copied ---
custom_headers after call: {'Auth': 'token', 'X-Request-ID': 'xyz'}
Response headers is same object: True
Key concepts demonstrated:
-
__defaults__vs__kwdefaults__: Regular positional defaults are stored in__defaults__(a tuple). Keyword-only parameter defaults (those after*) are stored in__kwdefaults__(a dict). Both are inspectable on the function object. -
All None sentinels:
__kwdefaults__shows{'headers': None, 'body': None, 'errors': None}— no mutable objects stored. Every call that omits these arguments gets a fresh{}or[]created inside the function body. -
Caller-provided mutables are shared by design: When the caller passes
custom_headers, the function uses that exact dict. Mutations to the response headers are visible throughcustom_headers. This is intentional — the caller provided the object and owns it. The None sentinel pattern only protects against the case where no argument is passed. -
Production pattern: This is exactly how response builders, request factories, and configuration constructors work in production Python. Libraries like Flask, Django REST Framework, and FastAPI all use this pattern extensively.
Expected Output
--- Basic usage ---
Response 1: {'status': 200, 'headers': {'X-Request-ID': 'abc'}, 'body': {}, 'errors': []}
Response 2: {'status': 404, 'headers': {}, 'body': {}, 'errors': ['Not found']}
Independent: True
--- __kwdefaults__ inspection ---
__defaults__: (200,)
__kwdefaults__: {'headers': None, 'body': None, 'errors': None}
--- Caller-provided mutable not copied ---
custom_headers after call: {'Auth': 'token', 'X-Request-ID': 'xyz'}
Response headers is same object: TrueHints
Hint 1: Keyword-only parameters (after `*`) have their defaults stored in `__kwdefaults__` instead of `__defaults__`. Use `None` sentinel for all mutable keyword-only defaults.
Hint 2: When the caller provides their own mutable object, the function should use it directly (not copy it) — the caller owns that object and may want to see mutations. Only create fresh objects when the caller omits the argument.
