DEV Community

Real Dev
Real Dev

Posted on

Python's mutable default argument bug, run one call at a time

This function looks fine. It adds a tag to a list and returns the list. If you don't pass a list, it starts a new empty one. At least that's what it seems to do.

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

print(add_tag("python"))
print(add_tag("sql"))
print(add_tag("dsa", []))
print(add_tag("git"))
Enter fullscreen mode Exit fullscreen mode
['python']
['python', 'sql']
['dsa']
['python', 'sql', 'git']
Enter fullscreen mode Exit fullscreen mode

The first call is fine. The second call returns ['python', 'sql'], even though we never passed a list. The third call passed its own list, so it got a clean result. Then the fourth call picked up where the second one left off.

So the "new empty list" is not new. It's the same list every time you skip the argument.

The default is built once

Python evaluates a default value when it runs the def line. It does not do it again on each call. The result gets stored on the function object, in __defaults__, and every call that skips the argument gets that one stored object.

You can look at it directly.

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

print(add_tag.__defaults__)
first = add_tag("python")
second = add_tag("sql")
print(add_tag.__defaults__)
print(first is second)
print(first is add_tag.__defaults__[0])
Enter fullscreen mode Exit fullscreen mode
([],)
(['python', 'sql'],)
True
True
Enter fullscreen mode Exit fullscreen mode

Before any call, the stored default is an empty list. After two calls it holds both tags. first and second are the same object, and that object is the one sitting in __defaults__.

If you want proof of when the default gets built, put a print inside the code that builds it.

def make_default():
    print("  building the default list")
    return []

print("before def")

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

print("after def")
add_tag("a")
add_tag("b")
print("after two calls")
Enter fullscreen mode Exit fullscreen mode
before def
  building the default list
after def
after two calls
Enter fullscreen mode Exit fullscreen mode

"building the default list" prints once, between "before def" and "after def". The two calls don't print it at all. They reuse what was built when the function was defined.

This is also why the bug hides in tests. If each test calls the function once, every test passes. It shows up when the same process calls it twice, which is what a web server or a long script does all day.

The same bug in a class

Classes hit this a lot, because __init__ is just a function with defaults.

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

    def add(self, item):
        self.items.append(item)

asha = Cart()
ravi = Cart()
asha.add("notebook")
print("asha:", asha.items)
print("ravi:", ravi.items)
print(asha.items is ravi.items)
Enter fullscreen mode Exit fullscreen mode
asha: ['notebook']
ravi: ['notebook']
True
Enter fullscreen mode Exit fullscreen mode

Ravi never added anything, but his cart has a notebook. Both carts point at the one list that was built when __init__ was defined. In a real app, that's one user seeing another user's data.

The fix is None

Use None as the default and build the list inside the function. The body runs on every call, so each call gets its own list.

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

print(add_tag("python"))
print(add_tag("sql"))
mine = ["dsa"]
print(add_tag("git", mine))
print(mine)
Enter fullscreen mode Exit fullscreen mode
['python']
['sql']
['dsa', 'git']
['dsa', 'git']
Enter fullscreen mode Exit fullscreen mode

Two calls with no list now give two separate results. And when you do pass a list, the function still adds to it, which is what a caller who passes a list usually wants.

Use if tags is None, not if not tags. The short version looks the same but treats an empty list you passed in as "no list".

def keep_count(tags=None):
    tags = tags or []
    tags.append("x")
    return tags

shared = []
keep_count(shared)
keep_count(shared)
print(shared)
Enter fullscreen mode Exit fullscreen mode
[]
Enter fullscreen mode Exit fullscreen mode

The caller passed shared and expected two "x" values in it. It's still empty. An empty list is falsy, so tags or [] threw it away and made a fresh one each time. The appends went into lists nobody kept.

Dataclasses refuse to do it

If you write the class with @dataclass, Python stops you from making this mistake for a list field.

from dataclasses import dataclass, field

try:
    @dataclass
    class Cart:
        items: list = []
except ValueError as e:
    print("ValueError:", e)

@dataclass
class Cart:
    items: list = field(default_factory=list)

a, b = Cart(), Cart()
a.items.append("pen")
print(a, b)
Enter fullscreen mode Exit fullscreen mode
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
Cart(items=['pen']) Cart(items=[])
Enter fullscreen mode Exit fullscreen mode

The error happens when the class is defined, so you find out right away. field(default_factory=list) calls list() for each new instance, and the two carts end up with separate lists. Note that the check is not perfect. On 3.13 it rejected list, dict, set and bytearray defaults, but a mutable class of your own can still slip through if it is hashable.

When people do it on purpose

You will sometimes see a mutable default used as a cache that lives between calls.

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

print(fib(80))
print(len(fib.__defaults__[0]))
Enter fullscreen mode Exit fullscreen mode
23416728348467685
79
Enter fullscreen mode Exit fullscreen mode

Every result goes into memo, and memo survives between calls. After one call it holds 79 entries.

It works. But a reader can't tell if it was on purpose or an accident, and nobody can clear the cache without reaching into __defaults__. functools.lru_cache does the same job and says what it's doing. If you do keep a trick like this, leave a comment.

What to check in your own code

Search for =[], ={} and =set() in function signatures. Each one is either a bug waiting for a second call or a cache that needs a comment.

The same goes for any default that is an object you can change, like a Cart() or a datetime.now() call. The second one isn't mutable, but it has the same timing problem. It runs once, at definition time, so every call gets the same time.

When you review code, ask one question about each default value. Is this built once, and is that what we want?

I work on Schoolabe, a free coding practice site, and small "predict the output" problems like these are what we put in the logic building section.

Top comments (0)