Menu

Python interview question · Question 4 of 9

Why are mutable default arguments dangerous in Python?

  • Easy
  • conceptual / debugging
  • ~5 min
  • Foundational relevance
  • 3 min read
  • Updated Oct 2026

Short answer

Default values are evaluated once, when the def statement runs, not on every call. If the default is a mutable object such as a list, dict or set, every call that relies on the default gets the same object, so changes made in one call leak into the next. In a pipeline that can mean rows from yesterday's batch appearing in today's, or settings changed by one task affecting another in the same worker process. The fix is to use None as the default and create a new object inside the function, or to use an immutable default such as a tuple or frozenset.

On this page
  1. Detailed explanation
  2. The fix
  3. The same trap with time
  4. Dataclasses
  5. Trade-offs and pitfalls
  6. Common mistakes

Detailed explanation

def f(rows=[]) creates the list once, stores it on the function object (f.__defaults__) and reuses it for every call that omits rows. Appending to it changes the stored default.

def collect_bad_rows(row, bad_rows=[]):          # the bug
    if row.get("amount") is None:
        bad_rows.append(row)
    return bad_rows

print(collect_bad_rows({"id": 1, "amount": None}))
print(collect_bad_rows({"id": 2, "amount": 5}))   # a good row, yet the list is not empty
print(collect_bad_rows.__defaults__)
[{'id': 1, 'amount': None}]
[{'id': 1, 'amount': None}]
([{'id': 1, 'amount': None}],)

The second call reports a bad row it never saw. In a long-running worker (an Airflow worker, a Lambda container that is reused, a streaming consumer), the list keeps growing across runs until memory runs out or wrong counts are published.

The fix

def collect_bad_rows(row, bad_rows=None):
    if bad_rows is None:
        bad_rows = []                             # a new list on every call
    if row.get("amount") is None:
        bad_rows.append(row)
    return bad_rows

print(collect_bad_rows({"id": 1, "amount": None}))
print(collect_bad_rows({"id": 2, "amount": 5}))
[{'id': 1, 'amount': None}]
[]

The same trap with time

Defaults that call functions are also evaluated once:

from datetime import datetime
import time

def stamp(loaded_at=datetime.now()):
    return loaded_at

first = stamp()
time.sleep(0.01)
print(stamp() == first)                           # the "current" time never changes
True

In a pipeline, prefer passing the run’s logical date explicitly anyway; reading the clock inside a job breaks reruns and backfills.

Dataclasses

dataclasses refuses a mutable default and points you at default_factory:

from dataclasses import dataclass, field

try:
    @dataclass
    class Bad:
        tables: list = []
except ValueError as exc:
    print("ValueError:", exc)

@dataclass
class LoadConfig:
    tables: list[str] = field(default_factory=list)

a, b = LoadConfig(), LoadConfig()
a.tables.append("orders")
print(a.tables, b.tables)
ValueError: mutable default <class 'list'> for field tables is not allowed: use default_factory
['orders'] []

Each LoadConfig gets its own list, so appending to one does not affect the other.

Trade-offs and pitfalls

  • Immutable defaults ((), frozenset(), None, numbers, strings) are safe.
  • The shared default is occasionally used on purpose as a cache, but functools.lru_cache or an explicit module-level dict says that more clearly.
  • Linters flag the pattern (for example Ruff’s B006), so enable them in CI.

Common mistakes

  1. def f(x=[]), def f(x={}) or def f(x=set()).
  2. Defaults computed from the clock or the environment at import time.
  3. Copying a default with list(default) but still mutating a nested object inside it.

By DataDank Editorial · Last reviewed Oct 2026 · Examples run on Python 3.11

Progress is saved in this browser only. No account needed.

Search
Filter by type