Ch. 9 · Python

Python Mutable Defaults and Shared State

Why def f(x, items=[]) keeps growing between calls, where Python stores that list, the None-sentinel fix, and why tags or [] is subtly wrong.

~5 min readbeginnerupdated Oct 7, 2026

This is probably the most asked Python “gotcha” in interviews, and it’s worth understanding properly rather than memorising the fix. The short version: a default argument value is created once, when the function is defined, not each time the function is called. If that value is a list or dict and the function mutates it, every call that relies on the default is working on the same object. The longer version explains where that object lives, why Python works this way, and the small variations that catch people who think they know the answer.

The list that remembers

Here’s a helper that adds a tag to a list of tags, creating one if you don’t pass it:

def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags

print(add_tag("urgent"))
print(add_tag("billing"))
print(add_tag("vip", ["existing"]))
print(add_tag("late"))
python
['urgent']
['urgent', 'billing']
['existing', 'vip']
['urgent', 'billing', 'late']
Text

The first call looks right. The second call was meant to return ['billing'] but still contains 'urgent'. The third call passed its own list, so it was unaffected. The fourth went back to the default and found everything the earlier default-using calls left behind.

Where the default actually lives

Python evaluates the expression [] when the def statement executes, which for a module-level function means once at import time. The resulting object is stored on the function in __defaults__:

print(add_tag.__defaults__)
a = add_tag("x")
b = add_tag("y")
print(a is b, a is add_tag.__defaults__[0])
python
(['urgent', 'billing', 'late'],)
True True
Text

That’s the whole mechanism. There’s one list, it’s attached to the function object, and every call that omits tags binds the parameter to it. Both a and b are that same list. Callers who keep the returned list and modify it are also modifying the default for future calls.

Why does Python do this instead of re-evaluating the default on each call? Partly cost and simplicity: defaults are computed once and then just bound. Partly consistency: a default is an expression evaluated in the scope where the function is defined, at definition time, like any other expression in that position. Some languages re-evaluate defaults per call; Python chose not to, and it’s been that way since the beginning. Knowing why matters in an interview less than being able to point at __defaults__ and show it.

This isn’t just about lists. Any default whose value is computed at definition time behaves the same way, mutable or not. Timestamps are the classic non-list version:

from datetime import datetime
import time

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

print(log("first"))
time.sleep(0.01)
print(log("second"))
python
22:33:17.656085 first
22:33:17.656085 second
Text

Both messages carry the same timestamp, down to the microsecond: the moment the module was imported. In a long-running service, every log line would show the startup time.

The None sentinel, and the version that’s almost right

The standard fix uses None as a marker meaning “not provided” and creates the fresh object inside the body, where code runs on every call:

def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

print(add_tag("urgent"), add_tag("billing"))
python
['urgent'] ['billing']
Text

A shorter version shows up a lot, and it has a subtle bug:

def add_tag_or(tag, tags=None):
    tags = tags or []
    tags.append(tag)
    return tags

mine = []
add_tag_or("urgent", mine)
print("caller's list:", mine)
python
caller's list: []
Text

The caller passed an empty list, expecting it to be filled in. But an empty list is falsy, so tags or [] replaced it with a new list, and the caller’s list was never touched. With if tags is None, the same call fills the caller’s list (['urgent']). Interviewers who know this topic well will sometimes ask about or [] specifically, to see whether you distinguish “not passed” from “passed but empty.”

If None is itself a meaningful value for the parameter, use a private sentinel object instead: _MISSING = object() and if tags is _MISSING:.

The same bug in classes and dataclasses

The bug survives being moved into __init__, where it’s even easier to miss because each instance seems independent:

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

c1, c2 = Cart(), Cart()
c1.items.append("book")
print(c2.items)
python
['book']
Text

Two carts, one list. Every customer who didn’t pass items shares a basket. Dataclasses guard against the obvious form of this by raising ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory when you write items: list = []. The fix there is field(default_factory=list), which calls list() per instance; the dataclass guide covers it.

Class attributes have a similar trap. class Cart: items = [] defines one list on the class, shared by every instance until an instance assigns its own self.items. Appending through self.items.append(...) doesn’t assign, so it mutates the shared class list.

When sharing is intentional

Occasionally people use a mutable default on purpose, as a cache that persists between calls:

def fib(n, _memo={0: 0, 1: 1}):
    if n not in _memo:
        _memo[n] = fib(n - 1) + fib(n - 2)
    return _memo[n]

print(fib(80), len(fib.__defaults__[0]))
python
23416728348467685 81
Text

It works, and the underscore signals “don’t pass this.” But it exposes the cache as a parameter anyone can override, it’s surprising to readers, and it grows forever. functools.cache or functools.lru_cache(maxsize=...) gives the same speed-up with a clear name, a size limit and a cache_clear() method for tests. In an interview, mentioning that you’d recognise this idiom but reach for functools instead is a good signal.

Linters catch the accidental version. Ruff and flake8-bugbear flag mutable defaults as B006, and function calls in defaults (like datetime.now()) as B008. Enabling those rules is cheaper than finding the bug in production.

Interview exercise: the leaking request context

A web handler calls def build_context(user, extra={}): extra["user"] = user.name; return extra. Most requests don’t pass extra. After a few hours, support reports that some pages show another user’s name. Explain how, and fix it.

Answer

The {} default was created once, when the module was imported, and every request that omits extra gets that same dict. Each call writes extra["user"], so the shared dict always holds the name from the most recent request. Because the function returns the dict, the template rendering one request can read a value written by another request in between, especially under concurrency in a threaded server, where two requests can interleave between the write and the read. Any other keys added by callers to the returned dict also leak into later requests. The fix is def build_context(user, extra=None): followed by extra = {} if extra is None else dict(extra), and then adding the user key. The dict(extra) copy is a deliberate extra: it also stops the function from mutating a dict the caller passed in, which keeps the function free of side effects on its inputs.

More on this topic

More in Python

read ✓Python · easy

Python Shallow Copies and Nested Mutability

Assignment, shallow copy and deepcopy compared on a nested config dict, plus the [[0]*3]*3 grid bug and what deepcopy costs in practice.

~5 min readread →
read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc