Type something to search...
Default Argument Pitfalls in Python: The Mutable Default Trap

Default Argument Pitfalls in Python: The Mutable Default Trap

Almost every Python developer hits this bug once. You write a function with a list as its default argument, call it a few times, and the list starts remembering values from earlier calls. Nothing in the code looks wrong, there's no global variable in sight, and yet state leaks from one call to the next.

The cause is a single rule about when Python evaluates default values. Once you know it, the bug stops being mysterious, and you can spot it in code review in a second. In this post I'll walk through the rule, show the places the trap hides (including classes and dataclasses), cover the standard fixes like the None sentinel and default_factory, and point out a couple of cases where the "trap" is actually used on purpose.

The Bug in Five Lines

Here's the classic version:

# cart.py
def add_item(item, items=[]):
    items.append(item)
    return items


print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry", []))
print(add_item("date"))

You'd probably expect each call without a second argument to start from an empty list. That's not what happens:

['apple']
['apple', 'banana']
['cherry']
['apple', 'banana', 'date']

The second call returns both fruits. The third call passes its own list, so it's isolated. The fourth call goes back to the default and finds "apple" and "banana" still sitting in it.

Why It Happens: Defaults Are Evaluated Once

Python evaluates default argument expressions once, when the def statement runs, not each time the function is called. The resulting objects are stored on the function object itself. Every call that doesn't supply that argument gets the same object.

You can see this directly. A function's positional defaults live in __defaults__:

print(add_item.__defaults__)
(['apple', 'banana', 'date'],)

There's exactly one list, and it's attached to add_item for as long as the function exists. When the body calls items.append(item), it mutates that shared list in place.

You can also prove the timing with a default that has a side effect:

def show_default(x=print("evaluating default")):
    return x


print("function defined")
evaluating default
function defined

The print in the default runs while the function is being defined, before anything calls it. It never runs again.

Mutation vs. Rebinding

The trap only bites when you mutate the default. If you rebind the parameter name to a new object, the default is untouched:

def add_one(n=0):
    n = n + 1   # rebinding: creates a new int, default is unaffected
    return n

Integers, strings, tuples, and None are immutable, so you can't mutate them even if you try. That's why n=0 or name="" is always safe, while items=[], options={}, and seen=set() are the risky ones. If the difference between mutating an object and rebinding a name is fuzzy, the post on variables and mutability goes through it in depth.

Keyword-Only Defaults Too

Keyword-only parameters (the ones after *) have the same behavior. Their defaults are stored in __kwdefaults__ instead:

def tag(name, *, labels=[]):
    labels.append(name)
    return labels


tag("a")
tag("b")
print(tag.__kwdefaults__)
{'labels': ['a', 'b']}

The Standard Fix: Use None as a Sentinel

The idiomatic fix is to default to None and create a fresh object inside the function:

def add_item(item: str, items: list[str] | None = None) -> list[str]:
    if items is None:
        items = []
    items.append(item)
    return items


print(add_item("apple"))
print(add_item("banana"))
['apple']
['banana']

Now the [] expression runs on every call that needs it, so each call gets its own list. The type hint list[str] | None tells readers and type checkers that None is an accepted value, and the if items is None check narrows it back to list[str] for the rest of the body.

Don't Use or as a Shortcut

You'll often see this shorter version:

def add_item(item, items=None):
    items = items or []
    items.append(item)
    return items

It works for the default case, but it has a subtle bug. items or [] treats any falsy value as missing, and an empty list is falsy. If a caller passes their own empty list expecting it to be filled, the function silently replaces it:

mine = []
add_item("x", mine)
print(mine)
[]

The caller's list never received "x". Always use an explicit is None check when the parameter is a container. The truthiness post covers why empty containers are falsy and where else that catches people out.

When None Is a Valid Value: A Custom Sentinel

Sometimes None is a meaningful argument and you need to tell "not passed" apart from "passed None". Create a unique private object and compare against it by identity:

_MISSING = object()


def get_setting(name: str, default: object = _MISSING) -> object:
    settings = {"debug": None}
    if name in settings:
        return settings[name]
    if default is _MISSING:
        raise KeyError(name)
    return default


print(get_setting("debug"))
print(get_setting("port", 8000))
try:
    get_setting("port")
except KeyError as e:
    print("KeyError:", e)
None
8000
KeyError: 'port'

object() creates a new instance that nothing else can be identical to, so default is _MISSING is true only when the caller left the argument out. This is the same technique the standard library uses internally in several places, and it's the right tool for functions like dict.get-style lookups.

It's Not Only Lists: Other Ways the Trap Shows Up

Dictionaries and Sets

Any mutable container behaves the same way:

def count(word, counts={}):
    counts[word] = counts.get(word, 0) + 1
    return counts


count("a")
count("b")
print(count("a"))
{'a': 2, 'b': 1}

Function Calls in Defaults

The trap isn't limited to mutable objects. Any expression in a default is evaluated once, including function calls. A timestamp default is a common example:

from datetime import datetime


def log(msg: str, when: datetime = datetime.now()) -> str:
    return f"{when:%H:%M:%S.%f} {msg}"

Every call to log() without when gets the time the module was imported, not the current time. A long-running service would stamp every log line with the same moment. The fix is the same sentinel pattern:

from datetime import datetime


def log(msg: str, when: datetime | None = None) -> str:
    if when is None:
        when = datetime.now()
    return f"{when:%H:%M:%S.%f} {msg}"

The same applies to defaults like uuid.uuid4(), random.random(), or reading a config file. If the value should be computed per call, compute it inside the function.

Class __init__ Methods

Constructors are where this bug does the most damage, because the shared object ends up stored on every instance:

class Cart:
    def __init__(self, items=[]):
        self.items = items


c1 = Cart()
c2 = Cart()
c1.items.append("book")
print(c2.items)
print(c1.items is c2.items)
['book']
True

Two carts that were supposed to be independent share one list. Adding to one cart adds to every cart created without an explicit argument. Use items: list[str] | None = None and build the list inside __init__, exactly as with plain functions. If you want a refresher on constructors generally, see how to use the __init__ method.

Dataclasses Catch It for You

Dataclasses refuse the most common form of the mistake outright:

from dataclasses import dataclass


@dataclass
class Bad:
    items: list = []
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

The check covers list, dict, and set (more precisely, any unhashable default). The fix is field(default_factory=...), which calls the factory each time an instance is created:

from dataclasses import dataclass, field


@dataclass
class Order:
    items: list[str] = field(default_factory=list)


o1 = Order()
o2 = Order()
o1.items.append("pen")
print(o1, o2)
Order(items=['pen']) Order(items=[])

Pydantic models and attrs classes have their own equivalents (Field(default_factory=list) and attrs.Factory(list) respectively). Pydantic is more forgiving than dataclasses here: it copies a mutable default for each instance, but default_factory still makes the intent clearer.

Prefer Immutable Defaults When You Can

If a function only reads a sequence and never modifies it, you don't need None at all. An empty tuple is immutable, so it's a perfectly safe default:

from collections.abc import Sequence


def total(values: Sequence[int] = ()) -> int:
    return sum(values)


print(total(), total([1, 2, 3]))
0 6

Typing the parameter as Sequence[int] instead of list[int] also documents that the function won't mutate it, and lets callers pass lists, tuples, or ranges.

For a mapping of constant defaults, you can expose a read-only view with types.MappingProxyType and copy it when you need a mutable version:

from types import MappingProxyType

DEFAULT_HEADERS = MappingProxyType({"Accept": "application/json"})


def build_headers(extra: dict[str, str] | None = None) -> dict[str, str]:
    headers = dict(DEFAULT_HEADERS)
    if extra:
        headers.update(extra)
    return headers


print(build_headers())
print(build_headers({"X-Trace": "1"}))
{'Accept': 'application/json'}
{'Accept': 'application/json', 'X-Trace': '1'}

Any attempt to write to DEFAULT_HEADERS directly raises TypeError: 'mappingproxy' object does not support item assignment, so an accidental mutation fails loudly instead of corrupting shared state.

Note that if extra: is fine here, unlike the or shortcut earlier: an empty extra dict has nothing to merge, so treating it like None changes nothing.

When the "Trap" Is Used on Purpose

Because defaults persist, people have historically used them deliberately.

Early Binding in Loops and Lambdas

The best-known legitimate use fixes the late-binding closure problem:

funcs = [lambda: i for i in range(3)]
print([fn() for fn in funcs])

funcs = [lambda i=i: i for i in range(3)]
print([fn() for fn in funcs])
[2, 2, 2]
[0, 1, 2]

The first set of lambdas all look up i when they're called, after the loop has finished, so they all see 2. In the second set, i=i captures the current value at definition time, which is exactly the "evaluated once" behavior that causes the mutable default bug. Here it's what you want. functools.partial is often a clearer way to express the same thing.

A Poor Man's Cache

You'll sometimes see a mutable default used as a cache:

def fib(n, _cache={}):
    if n in _cache:
        return _cache[n]
    result = n if n < 2 else fib(n - 1) + fib(n - 2)
    _cache[n] = result
    return result


print(fib(30))
832040

It works, but it's a hidden global with a confusing signature: callers can see and pass _cache. functools.cache (or lru_cache with a size limit) does the same job with a clean signature and proper tooling like cache_info() and cache_clear(). Reach for that instead.

Catching It Automatically

You shouldn't have to rely on memory or code review. Linters flag mutable defaults reliably:

  • Ruff has rule B006 (mutable argument default) and B008 (function call in default argument), both from the flake8-bugbear set. Enable them with the B selector.
  • Pylint reports W0102 (dangerous-default-value).

A minimal Ruff configuration:

# pyproject.toml
[tool.ruff.lint]
extend-select = ["B"]

With that in place, the original add_item gets flagged before it's ever committed. The upcoming post on linting and formatting with Ruff covers the rest of the setup.

A Quick Checklist

When you write or review a function signature, ask:

  • Is any default a list, dict, set, or other mutable object? Replace it with None (or an immutable equivalent like ()) and build the object inside.
  • Is any default a function call (datetime.now(), uuid4(), load_config())? Move the call into the body.
  • Does the code use x = x or []? Switch to if x is None so caller-supplied empty containers aren't discarded.
  • Is None itself a meaningful argument? Use a private object() sentinel.
  • Is it a dataclass or Pydantic model? Use default_factory.

Conclusion

The mutable default trap comes down to one fact: default expressions run once, when the function is defined, and the resulting object is reused by every call. Mutating that object leaks state between calls, and calling a function in a default freezes its result at import time.

Default to None with an is None check, use default_factory in dataclasses, prefer immutable defaults when the function only reads its input, and turn on Ruff's B006 and B008 so the bug never lands in your codebase in the first place.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading