Skip to main content

Python Default Parameters Pitfalls: Practice Problems & Exercises

Practice: Default Parameters Pitfalls

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1The Classic Mutable Default BugEasy
mutable-defaultlistshared-statepredict-output

Predict the output of each print statement. Trace how the default list accumulates state across calls.

Python
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:

  1. append_to(1) — no items argument, so Python uses the default []. Appends 1. The default is now [1]. a points to this default list.
  2. append_to(2) — again uses the default, which is now [1]. Appends 2. The default is now [1, 2]. b points to the same default list.
  3. append_to(3, []) — an explicit [] is passed, so the default is not used. Appends 3 to the fresh list. The default list is untouched. c is a separate list.
  4. append_to(4) — uses the default again, which is still [1, 2]. Appends 4. The default is now [1, 2, 4]. d points to the same default list.
  5. a, b, and d all point to the same default list object, so a is b is d is True.
Expected Output
[1]
[1, 2]
[3]
[1, 2, 4]
True
Hints

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.

#2Inspecting __defaults__Easy
__defaults__introspectionfunction-objecttuple

Inspect __defaults__ before and after calling a function with a mutable default. Prove that the stored default is the same object that gets mutated.

Python
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_item appends to items, 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=0 is an integer (immutable) — it cannot be mutated, so it stays 0 forever.
  • id(add_item.__defaults__[0]) == id(result1) is True, 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: True
Hints

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.

#3None Sentinel Pattern — Fix the BugEasy
None-sentinelis-Nonefix-bugmutable-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.

Python
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
  1. The default value is None — immutable and cannot accumulate state across calls.
  2. Inside the function, if tasks is None checks whether the caller provided a value. If not, a new list is created on every call.
  3. If the caller passes an explicit list (like ["task4"]), tasks is None is False, 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: True
Hints

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.

#4Dict Default Accumulation BugEasy
mutable-defaultdictshared-statepredict-output

Predict the output. A function uses an empty dict as a default. Two calls build up configuration — trace which dict they share.

Python
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:

  1. make_config("db1")config defaults to the shared {}. Sets host to "db1" and port to 5432. Returns the dict. c1 points to it.
  2. At this point, printing c1 shows {'host': 'db1', 'port': 5432} — but this output happens before the next call, so the first print is correct.
  3. make_config("db2", 3306)config defaults to the same dict, which now has {'host': 'db1', 'port': 5432}. Overwrites host to "db2" and port to 3306.
  4. Now c1 and c2 both point to {'host': 'db2', 'port': 3306} — the second call overwrote the first call's values.
  5. c1 is c2 is True — same object.
  6. __defaults__ shows (5432, {'host': 'db2', 'port': 3306}) — wait, port=5432 is the default for the port parameter (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

#5datetime.now() Default TrapMedium
evaluation-timingdatetimedefinition-timeNone-sentinel

Demonstrate the datetime.now() default bug and fix it. The buggy version produces identical timestamps; the fixed version produces different ones.

Python
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_at fields
  • 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: True
Hints

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.

#6Late Binding Closures in LoopsMedium
late-bindingclosureloopdefault-argument-fix

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.

Python
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.

#7Class __init__ with Mutable DefaultMedium
class__init__mutable-defaultshared-stateNone-sentinel

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.

Python
# 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: False
Hints

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.

#8Set Default AccumulationMedium
mutable-defaultsetshared-stateNone-sentinel

Demonstrate that sets as default arguments have the same accumulation bug as lists and dicts. Show the buggy version, then fix it.

Python
# 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 DefaultFix
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: True
Hints

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

#9Intentional Mutable Default as CacheHard
mutable-defaultmemoizationcacheintentionallru_cache

Implement Fibonacci with intentional mutable default memoization, then implement the same with functools.lru_cache. Compare cache behavior and introspection capabilities.

Python
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:

FeatureMutable Defaultlru_cache
Cache clearingNot possible (must manually clear the dict)func.cache_clear()
Cache inspectionAccess via __defaults__[0] (fragile)func.cache_info() (official API)
Max size controlManual (must implement eviction)Built-in maxsize parameter
Thread safetyNot thread-safeThread-safe (uses a lock)
ReadabilityClever but surprisingExplicit 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.

#10Default Evaluation Order and Side EffectsHard
evaluation-timingside-effectsdefinition-time__defaults__introspection

Prove that defaults are evaluated left-to-right at definition time by using side-effect-producing expressions. Explore conditional definition and redefinition.

Python
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:

  1. Left-to-right evaluation: When Python executes def f(a=expr_a, b=expr_b, c=expr_c):, it evaluates expr_a, then expr_b, then expr_c, in order. All three print statements appear before "Function defined."

  2. Evaluated once, stored forever: The make_default functions are called only during def. Calling f() does NOT re-evaluate them — it just uses the stored values from __defaults__.

  3. Conditional definition: Only the def statement in the taken branch executes. make_default("branch_B") is never called because the else branch is not reached.

  4. Redefinition: Each def h(...) creates a new function object with new defaults. The old function object (and its defaults) is discarded. Both make_default calls execute — one when the first def runs, one when the second def runs.

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.

#11Safe Factory with Keyword-Only DefaultsHard
None-sentinelkeyword-only__kwdefaults__factory-patternproduction

Build a production-quality response factory using keyword-only parameters with None sentinels. Inspect __kwdefaults__ to verify no mutable objects are stored as defaults.

Python
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:

  1. __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.

  2. 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.

  3. 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 through custom_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.

  4. 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: True
Hints

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.

© 2026 EngineersOfAI. All rights reserved.