18.3 Default Argument Values and the Mutable Default Trap
When defining a function in Python, you can specify default values for parameters. This powerful feature allows callers to omit arguments for which sensible defaults exist, making APIs more flexible and concise. However, when these default values are mutable objects like lists, dictionaries, or sets, a subtle and often surprising behavior occurs that has ensnared many developers—a phenomenon commonly known as the “Mutable Default Argument Trap.”
How Default Arguments Work
Default argument values are evaluated exactly once—at the point of function definition when the def statement is executed. They are not re-evaluated each time the function is called. This behavior is a direct consequence of Python’s execution model. When the interpreter encounters a def statement, it compiles the function body into a code object and creates a function object. The default values are stored as a tuple in the function object’s __defaults__ attribute.
def func(a, b=10, c=[]):
c.append(a)
return c
print(func.__defaults__) # Output: (10, [])
result1 = func(1)
print(result1) # Output: [1]
result2 = func(2)
print(result2) # Output: [1, 2] (The list persists!)
In the example above, the default list c=[] is created once during the function’s definition. Every subsequent call to func() that uses the default value for c will be using that same original list object. Appending to it in one call affects what is seen in the next call, which is rarely the intended behavior.
The Mutable Default Trap Explained
The “trap” occurs because a developer often intends for the default mutable object to be a fresh, empty one on each call. Instead, they get a single, persistent object shared across all calls that do not provide an explicit argument. This leads to state “leaking” between independent function invocations, which can cause baffling bugs that are difficult to track down because the function’s behavior depends on its call history.
def add_item(item, shopping_list=[]):
"""Add an item to a shopping list."""
shopping_list.append(item)
return shopping_list
list1 = add_item('apples')
print(list1) # Output: ['apples']
list2 = add_item('bananas')
print(list2) # Output: ['apples', 'bananas'] # list1 and list2 are the same object!
print(list1 is list2) # Output: True (Proof they are identical)
Why Python Behaves This Way
This design choice is not a flaw but a conscious decision by the language creators, primarily for efficiency. Evaluating expressions for default arguments at definition time rather than call time is more performant, especially for expensive operations. For immutable objects like integers, strings, or tuples, this single evaluation is harmless and efficient. The potential pitfall only arises with mutable defaults.
The Canonical Solution: Using None as a Sentinel
The universally accepted best practice to avoid this trap is to use None as the default value and then create the new mutable object inside the function body. The function’s code checks if the argument received the sentinel value and instantiates a new mutable object if it did.
def add_item_fixed(item, shopping_list=None):
"""Correctly add an item to a shopping list."""
if shopping_list is None:
shopping_list = [] # Create a new list each time the default is needed
shopping_list.append(item)
return shopping_list
list1 = add_item_fixed('apples')
print(list1) # Output: ['apples']
list2 = add_item_fixed('bananas')
print(list2) # Output: ['bananas'] # This is now a separate, new list.
print(list1 is list2) # Output: False (Proof they are different objects)
This pattern ensures that a new, empty list is created on each call where the shopping_list argument is not provided. The check happens at runtime, every time the function is called, guaranteeing fresh mutable objects.
Advanced Consideration: Explicit Mutable Passes
It’s crucial to understand that the None sentinel pattern only creates a new object if the argument is omitted. If a caller explicitly passes a mutable object, the function will modify that passed-in object.
my_existing_list = ['milk']
result = add_item_fixed('bread', my_existing_list)
print(result) # Output: ['milk', 'bread']
print(my_existing_list) # Output: ['milk', 'bread'] (The original list was modified)
This is often the desired behavior, as it allows the function to be used both to create new lists and to append to existing ones. The function’s docstring should clearly document this behavior.
Other Mutable Defaults and Edge Cases
The trap applies to all mutable types, not just lists.
# Dictionary Example
def create_log(message, log_dict={}):
log_dict[message] = time.time()
return log_dict
# Set Example
def process_data(data, seen=set()):
if data in seen:
return
seen.add(data)
# ... process data ...
# Both of the above functions suffer from the same issue.
An often-overlooked edge case involves default arguments that are instances of custom classes, which are also mutable objects and subject to the exact same persistence behavior.
Best Practices Summary
- Never use mutable objects as default values: Avoid
def func(arg=[])ordef func(arg={}). - Use
Noneas a sentinel: Always default toNoneand check for it inside the function body. - Document the behavior: Clearly state in the docstring that a new object is created if the argument is omitted, or the passed-in object is modified otherwise.
- Be consistent: Apply this pattern universally to avoid mistakes, even if you “know” the function will only be called a certain way. Future code modifications might break that assumption.