Of all the “gotchas” I’ve run into in Python, this is the one I see catch even experienced developers off guard: using a mutable object like a list or dictionary as a default argument value. It looks completely innocent, it works fine in your first test, and then it quietly corrupts your data three function calls later. I want to walk through exactly why this happens, how to fix it properly, and what best practices I now follow whenever I define functions with optional arguments.
The Trap, Illustrated
Here’s the classic example, and I guarantee almost every Python developer has written something like it at least once:
def add_task(task, task_list=[]):
task_list.append(task)
return task_list
print(add_task("Write report")) # ['Write report']
print(add_task("Review code")) # ['Write report', 'Review code'] <- unexpected!
print(add_task("Deploy app")) # ['Write report', 'Review code', 'Deploy app']
I expected each call without an explicit task_list to start fresh with an empty list. Instead, the same list keeps growing across calls, silently accumulating state I never intended to share between unrelated calls.
Why This Happens: Default Arguments Are Evaluated Once
The root cause is a detail of how Python’s function definitions work: default argument values are evaluated exactly once, at the moment the def statement runs — not each time the function is called. That default value is then stored as an attribute on the function object itself.
I can actually inspect this directly:
def add_task(task, task_list=[]):
task_list.append(task)
return task_list
print(add_task.__defaults__) # (['Write report', 'Review code', 'Deploy app'],)
That tuple holds the exact same list object across every call that doesn’t explicitly supply task_list. Since lists are mutable, every .append() call modifies that one shared object permanently, for the lifetime of the function object (which, for a module-level function, is usually the lifetime of the program).
This isn’t a bug in Python — it’s a direct, logical consequence of two design decisions working together: default values are computed once, and mutable objects are shared by reference. It just produces a result most people don’t expect.
The Standard Fix: Use None as a Sentinel
The idiomatic solution I use every time is to set the default to None, then create the actual mutable object fresh inside the function body if none was passed in.
def add_task(task, task_list=None):
if task_list is None:
task_list = []
task_list.append(task)
return task_list
print(add_task("Write report")) # ['Write report']
print(add_task("Review code")) # ['Review code'] <- correct, independent list
This works because None is immutable and safe to reuse as a default. The actual mutable list is created inside the function body every time the function runs without an explicit argument, so each call gets its own fresh, independent list.
I can shorten this with the or operator in some cases, though I’m careful with it:
def add_task(task, task_list=None):
task_list = task_list or []
task_list.append(task)
return task_list
I only use this shorthand when I’m confident an empty list (or other falsy value) being passed in intentionally would never need to be preserved as-is — because task_list or [] will replace an empty list [] with a new empty list too, which is usually harmless but worth knowing.
Why Not Just Copy the Default Every Time?
Another approach I sometimes see is copying the default inside the function:
def add_task(task, task_list=[]):
task_list = list(task_list) # defensive copy
task_list.append(task)
return task_list
This technically avoids mutating the shared default, but I don’t like this pattern because the shared mutable default still exists in memory and is still a little confusing to read. The None-sentinel pattern is clearer about intent: it explicitly signals “if nothing is provided, build a new one.”
This Applies to Any Mutable Default, Not Just Lists
The same trap applies to dictionaries, sets, and any custom mutable object used as a default value.
def register_user(name, preferences={}):
preferences["name"] = name
return preferences
print(register_user("Alice")) # {'name': 'Alice'}
print(register_user("Bob")) # {'name': 'Bob'} <- same dict object reused!
Both calls actually return the same dictionary object, just mutated differently each time — a bug I’ve genuinely watched crash a Flask application because request-handling code assumed it was getting a clean dictionary per request.
The fix follows the same pattern:
def register_user(name, preferences=None):
if preferences is None:
preferences = {}
preferences["name"] = name
return preferences
Using dataclasses.field(default_factory=...)
If I’m working with a dataclass instead of a plain function, Python actually prevents me from using a mutable default directly — it raises a ValueError at class definition time, which I appreciate as a built-in safety net:
from dataclasses import dataclass, field
@dataclass
class TaskList:
tasks: list = field(default_factory=list)
t1 = TaskList()
t1.tasks.append("Write report")
t2 = TaskList()
print(t2.tasks) # [] — independent, as expected
default_factory takes a zero-argument callable — list, dict, set, or any custom function — and calls it fresh every time a new instance is created. This is essentially the same “create it fresh” philosophy as the None-sentinel pattern, but enforced by the dataclass machinery instead of manual code.
When Mutable Defaults Are (Rarely) Intentional
There’s actually one advanced pattern where a mutable default is used deliberately: as a way to cache state across calls, sometimes called a “poor man’s memoization” or used for simple counters.
def call_counter(_cache={"count": 0}):
_cache["count"] += 1
return _cache["count"]
print(call_counter()) # 1
print(call_counter()) # 2
print(call_counter()) # 3
I want to be very clear: I almost never use this pattern in production code, because it’s surprising to anyone reading the function signature without deep Python knowledge, and it makes the function stateful and harder to test in isolation. If I need caching or counters, I reach for functools.lru_cache, a class with an instance attribute, or a closure — all of which make the intent to hold state explicit rather than hiding it in a default argument.
How I Catch This in Code Review
Linters like pylint and flake8 (via the flake8-bugbear plugin, specifically rule B006) flag mutable default arguments automatically, and I keep this check enabled in every project I work on. It’s cheap insurance against a bug that’s easy to write and genuinely hard to notice until it causes weird behavior in production.
pip install flake8 flake8-bugbear
flake8 --select=B006 my_script.py
Best Practices Summary
- Never use
[],{},set(), or a mutable custom object directly as a default argument value. - Use
Noneas a sentinel default, and construct the mutable object inside the function body. - For dataclasses, use
field(default_factory=...)instead of a bare mutable default. - Enable linting (
flake8-bugbearor similar) to catch this automatically in code review. - If you genuinely want state to persist across calls, make that intention explicit — with a class, a closure, or
functools.lru_cache— rather than relying on default-argument behavior.
FAQs
Why does Python let me write buggy code like def f(x=[]) without warning me? Python’s default argument evaluation is a deliberate, documented design choice — defaults are evaluated once when the function is defined, for both performance and consistency reasons. It’s not a bug; it just produces unintuitive results when combined with mutable objects, which is why linters exist to flag the pattern.
Does this issue apply to tuples used as defaults? No, because tuples are immutable. def f(x=(1, 2, 3)) is completely safe — you can’t mutate the tuple in place, so there’s nothing to leak across calls.
Is task_list=None slower than task_list=[]? The difference is negligible — an extra if check and, in the common case, a single list construction. This is not a performance concern; it’s a correctness one.
Does this affect keyword-only arguments too? Yes — the mutable default problem applies to any default value in a function signature, whether it’s positional-or-keyword, keyword-only, or defined with *.
Summary
The mutable default argument trap is one of Python’s most well-known pitfalls precisely because it’s so easy to write without realizing anything is wrong — the code runs, it just runs wrong after the first call. Once I understood that default values are computed a single time at function-definition time, the behavior stopped being mysterious and started being predictable. My rule of thumb now is simple: if a default value could be mutated, it should never be mutable itself — use None and build the object inside the function.
