DEV Community

rene
rene

Posted on

My Python function remembered data from the last call. The bug was a mutable default argument

I called the same function twice and the second call returned the first call's data. No global variable, no cache, nothing obvious. The cause was one line in the function signature.

The bug

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

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']  <- expected ['b']
print(add_item("c"))  # ['a', 'b', 'c']
Enter fullscreen mode Exit fullscreen mode

Every call without an items argument shares the same list.

Why it happens

Python evaluates default argument values once, when the def statement runs, not each time the function is called. The list is created a single time and stored on the function object. You can see it:

print(add_item.__defaults__)  # (['a', 'b', 'c'],)
Enter fullscreen mode Exit fullscreen mode

The Python docs describe this in the section on default argument values: https://docs.python.org/3/tutorial/controlflow.html#default-argument-values

This affects any mutable default: lists, dicts, sets, and also objects such as datetime values you meant to be "now".

The fix

Use None as the default and create the object inside the function:

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

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b']
Enter fullscreen mode Exit fullscreen mode

Use is None, not if not items. An empty list the caller passes in on purpose is falsy, and you would silently replace it with a new one, which breaks callers who expect their own list to be filled.

The same trap with dicts and dataclasses

def log(event, ctx={}):      # shared dict, same bug
    ctx["last"] = event
    return ctx
Enter fullscreen mode Exit fullscreen mode

Dataclasses refuse this pattern for lists and dicts and raise a ValueError for mutable defaults. Use field(default_factory=list) instead: https://docs.python.org/3/library/dataclasses.html#mutable-default-values

How to catch it early

  • Linters flag it. Ruff has rule B006 (mutable argument default), and flake8-bugbear has the same check.
  • When a function seems to "remember" state, print func.__defaults__ first.

When the shared default is actually useful

Some people use it on purpose as a cheap memo cache. It works, but it is surprising to the next reader. A named module-level dict or functools.lru_cache says what you mean.

Quick checklist

  1. Any =[], ={} or =set() in a signature is a bug until proven otherwise.
  2. Default to None, build the object inside.
  3. Compare with is None.
  4. Turn on the B006 lint rule.

Have you ever shipped this bug, and how long did it take you to find it?

Top comments (0)