DEV Community

Cover image for The Python Bug That Fools Everyone: Mutable Default Arguments
Muiduzzaman Lipu for Refactorly

Posted on

The Python Bug That Fools Everyone: Mutable Default Arguments

This tiny function looks completely fine. It has a bug that has fooled beginners and senior developers alike:

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

print(add_item("apple"))   # ['apple']
print(add_item("banana"))  # ['apple', 'banana']  😱
Enter fullscreen mode Exit fullscreen mode

The second call returns ['apple', 'banana']. Where did apple come from? We never passed a list in.

Here's the 2-minute video version if you prefer watching:

Why it happens: defaults are created once

Default values are evaluated once, when the function is defined, not every time it's called. Every call that doesn't pass items shares the same list object.

You can actually see it, hiding inside the function:

>>> add_item("apple")
['apple']
>>> add_item("banana")
['apple', 'banana']
>>> add_item.__defaults__
(['apple', 'banana'],)
Enter fullscreen mode Exit fullscreen mode

The default list lives on the function object and grows with every call.

The fix: use None and create the list inside

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

Now every call that doesn't pass a list gets its own brand-new one. This is the standard Python pattern, and you'll see it all over the standard library.

Same trap, different shape: frozen timestamps

It's not just lists. Anything in a default is evaluated once:

from datetime import datetime

def log(msg, when=datetime.now()):  # evaluated once!
    print(when, msg)

log("first")
log("second")  # same time as "first" 🤔
Enter fullscreen mode Exit fullscreen mode

datetime.now() runs only when the function is defined, so every log line gets the same frozen timestamp, even hours later. Same fix: default to None, and compute the time inside the function.

Dataclasses protect you (use default_factory)

Dataclasses refuse mutable defaults outright:

@dataclass
class Cart:
    items: list = []

# ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
Enter fullscreen mode Exit fullscreen mode

The correct way:

from dataclasses import dataclass, field

@dataclass
class Cart:
    items: list = field(default_factory=list)
Enter fullscreen mode Exit fullscreen mode

default_factory builds a new list for every instance.

Bonus pitfall: items = items or []

You'll often see this shortcut:

def fill(items=None):
    items = items or []   # [] is falsy!
    items.append(1)

mine = []
fill(mine)
print(mine)  # [] (your list was ignored!)
Enter fullscreen mode Exit fullscreen mode

An empty list is falsy, so if the caller passes their own empty list, it gets silently replaced with a new one. Always check is None explicitly:

def fill(items=None):
    if items is None:
        items = []
    items.append(1)

mine = []
fill(mine)
print(mine)  # [1] ✅
Enter fullscreen mode Exit fullscreen mode

Recap

  • Default values are evaluated once, at definition time
  • Never use [], {} or set() as defaults
  • Use None and create the object inside the function
  • In dataclasses: field(default_factory=list)
  • Check is None, not or

Have you been bitten by this one? Tell me in the comments.


I post one tested coding tip every day on YouTube at Refactorly. The 30-second version of this one is here.

Top comments (0)