DEV Community

Krishna
Krishna

Posted on

The Python Default Argument Bug That Looks Completely Normal

Here's a Python function that looks completely normal:

def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags
Enter fullscreen mode Exit fullscreen mode

Give it a tag and, optionally, an existing list of tags.

If you don't pass a list, it looks like the function should start with an empty one.

Right?

Let's try it.

print(add_tag("urgent"))
print(add_tag("billing"))
print(add_tag("refund"))
Enter fullscreen mode Exit fullscreen mode

Output:

['urgent']
['urgent', 'billing']
['urgent', 'billing', 'refund']
Enter fullscreen mode Exit fullscreen mode

Wait. What?

Each call was supposed to start with an empty list. Instead, every call remembers the tags from the previous ones.

The function isn't broken. Python is doing exactly what it's supposed to.

The problem is how we understand default arguments.

Here's the one sentence that explains it:

Python evaluates a default argument once, when the def statement runs, not every time the function is called.

There's only one empty list, and every call that doesn't provide its own list shares it.

Let me show you how to prove that, why it can cause real problems, and how to fix it.

Step 1: Watch Python reuse the same list

Python stores a function's positional default arguments in an attribute called __defaults__.

We can inspect it directly.

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

print(add_tag.__defaults__)

add_tag("urgent")
add_tag("billing")

print(add_tag.__defaults__)
Enter fullscreen mode Exit fullscreen mode

Output:

([],)
(['urgent', 'billing'],)
Enter fullscreen mode Exit fullscreen mode

Look at that.

The default started as an empty list.

After two calls, the function's default itself contains "urgent" and "billing".

The default isn't an instruction to create a new empty list. It's a reference to one specific list object.

We can even prove both calls return that same object:

first = add_tag("a")
second = add_tag("b")

print(first is second)  # True
Enter fullscreen mode Exit fullscreen mode

is checks whether two variables refer to the same object.

And they do.

That's why changes made during one call show up in later calls.

Step 2: It's not really a list problem

Most explanations call this the "mutable default argument bug."

That's accurate, but there's something more important underneath.

The actual rule is about when Python evaluates the default expression.

Lists make the problem obvious because you can modify them. But the timing also matters for values that aren't mutable.

Consider this:

import time
from datetime import datetime

def log(message, at=datetime.now()):
    print(f"[{at:%H:%M:%S}] {message}")

log("job started")

time.sleep(2)

log("job finished")
Enter fullscreen mode Exit fullscreen mode

You might expect two different timestamps.

But the output looks like this:

[10:15:03] job started
[10:15:03] job finished
Enter fullscreen mode Exit fullscreen mode

Your actual timestamp will differ, but both messages will show the same time.

Why?

Because datetime.now() ran when Python executed the def log(...) statement.

It didn't run again when we called log().

So both messages reused the same timestamp, even though we waited two seconds between calls.

Once you understand this, the list problem stops looking like some strange Python exception.

It's the same rule in both cases.

The default expression runs once when the function is defined, and its result is reused.

Step 3: Where this becomes a real problem

The tag example is annoying, but mostly harmless.

Now imagine a function that validates batches of orders.

It collects warnings for orders with missing amounts.

def validate_orders(orders, warnings=[]):
    for order in orders:
        if order["amount"] is None:
            warnings.append(
                f"order {order['id']}: missing amount"
            )

    return warnings
Enter fullscreen mode Exit fullscreen mode

Let's process Monday's orders:

monday = [
    {"id": 1, "amount": 90},
    {"id": 2, "amount": None}
]

print("Monday:", validate_orders(monday))
Enter fullscreen mode Exit fullscreen mode

Output:

Monday: ['order 2: missing amount']
Enter fullscreen mode Exit fullscreen mode

That's correct.

Order 2 has a missing amount.

Now let's process Tuesday's orders:

tuesday = [
    {"id": 3, "amount": 40}
]

print("Tuesday:", validate_orders(tuesday))
Enter fullscreen mode Exit fullscreen mode

Output:

Tuesday: ['order 2: missing amount']
Enter fullscreen mode Exit fullscreen mode

Tuesday's data is completely valid.

But its validation report still contains Monday's warning.

Nothing about Tuesday's input caused that warning.

It was carried over through the shared default list.

Now imagine this function inside a long-running application.

A scheduled worker processing different jobs. A notebook that's been open all afternoon. Or a web server handling requests from multiple users.

If the same function object handles those calls, state stored in a mutable default can carry across them.

Depending on what you're storing, that can lead to incorrect results or even expose one request's data in another response.

And a test that only calls the function once might never catch it.

The bug becomes obvious when you test repeated calls, not just isolated ones.

Step 4: Fix it with None

The usual fix is simple.

Instead of using an empty list as the default, use None.

Then create the list inside the function.

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

    tags.append(tag)
    return tags

print(add_tag("urgent"))
print(add_tag("billing"))
Enter fullscreen mode Exit fullscreen mode

Output:

['urgent']
['billing']
Enter fullscreen mode Exit fullscreen mode

Now each call that omits tags gets a fresh list.

Why does this work?

Because None is the shared default, but the list gets created inside the function body.

That code runs every time we call the function without supplying a list.

And if someone passes an existing list, we still use it:

my_tags = ["important"]

add_tag("review", my_tags)

print(my_tags)
Enter fullscreen mode Exit fullscreen mode

Output:

['important', 'review']
Enter fullscreen mode Exit fullscreen mode

So we get the behavior we actually wanted.

No list provided? Create one.

List provided? Use that one.

The timestamp example needs the same treatment:

def log(message, at=None):
    if at is None:
        at = datetime.now()

    print(f"[{at:%H:%M:%S}] {message}")
Enter fullscreen mode Exit fullscreen mode

Now the timestamp is generated when log() runs, unless the caller explicitly provides one.

Step 5: Be careful with tags or []

There's a shorter version of this fix that you'll see in Python code.

It looks convenient:

def add_tag(tag, tags=None):
    tags = tags or []

    tags.append(tag)
    return tags
Enter fullscreen mode Exit fullscreen mode

And it appears to work.

Until someone passes an empty list intentionally.

my_tags = []

add_tag("urgent", my_tags)

print(my_tags)
Enter fullscreen mode Exit fullscreen mode

Output:

[]
Enter fullscreen mode Exit fullscreen mode

Wait, where did "urgent" go?

The function did append it, but not to my_tags.

Here's the problem:

tags = tags or []
Enter fullscreen mode Exit fullscreen mode

An empty list is falsy in Python.

So when tags is [], the expression chooses a new list instead.

The caller's original list gets ignored.

That's why this version is safer:

if tags is None:
    tags = []
Enter fullscreen mode Exit fullscreen mode

It creates a new list only when the argument is actually None.

An empty list passed by the caller remains an empty list that the function can modify.

Small difference. Very different behavior.

Step 6: Python dataclasses have a similar safeguard

There's another place where this issue shows up.

Suppose you're using a dataclass to represent a support ticket.

You might try:

from dataclasses import dataclass

@dataclass
class Ticket:
    tags: list[str] = []
Enter fullscreen mode Exit fullscreen mode

But Python raises a ValueError because a mutable list is being used as a dataclass field default.

Instead, use field(default_factory=list).

from dataclasses import dataclass, field

@dataclass
class Ticket:
    tags: list[str] = field(default_factory=list)
Enter fullscreen mode Exit fullscreen mode

Now let's create two tickets.

first = Ticket()
second = Ticket()

first.tags.append("urgent")

print(first.tags)
print(second.tags)
Enter fullscreen mode Exit fullscreen mode

Output:

['urgent']
[]
Enter fullscreen mode Exit fullscreen mode

Each ticket gets its own list.

default_factory=list tells the dataclass to call list() when creating a new instance, rather than sharing one list across all instances.

It's the same underlying idea as our None fix: create the mutable object when it's actually needed.

Step 7: Let your linter catch this

You don't have to rely entirely on remembering this rule.

Python linters can flag suspicious default arguments before the code runs.

For example:

  • Ruff / flake8-bugbear B006 catches mutable argument defaults like tags=[].
  • Ruff B008 flags function calls in default arguments, such as datetime.now().
  • Pylint W0102 warns about dangerous mutable defaults.

If you're using Ruff, you can check for both of these patterns with:

ruff check --select B006,B008 .
Enter fullscreen mode Exit fullscreen mode

That doesn't replace understanding the behavior, but it's a useful way to catch mistakes during development.

And one important distinction: not every default argument is dangerous.

def greet(name, punctuation="!"):
    return f"Hello, {name}{punctuation}"
Enter fullscreen mode Exit fullscreen mode

Strings, numbers, and other immutable values are generally fine to reuse.

Even an empty tuple () is a reasonable default when you only need a read-only sequence.

Just remember that an immutable container can still hold mutable objects.

And if you genuinely want state shared across calls, make that intention explicit. For caching, something like functools.lru_cache is usually clearer than hiding a dictionary in a default argument.

The mental model that finally makes sense

I used to read this:

def add_tag(tag, tags=[]):
Enter fullscreen mode Exit fullscreen mode

as:

"If no list is passed, create an empty list."

But Python actually treats it more like:

"When this function is defined, create one empty list. Whenever a caller doesn't supply a list, use that same one."

That's a very different thing.

Once you understand that default expressions run when the def statement executes, the shared list and frozen timestamp make sense.

And the fix becomes obvious.

If a default value needs to be fresh for every call, create it inside the function.

Try it yourself

Take the validate_orders() example and run it with three different batches.

Then inspect:

print(validate_orders.__defaults__)
Enter fullscreen mode Exit fullscreen mode

You'll see the old warnings sitting inside the default list.

Change the function to use warnings=None, create a fresh list inside the function, and try again.

This time, __defaults__ should remain (None,), no matter how many batches you process.

I'm building Runtime, a free hands-on learning platform for Python and SQL where you can practice concepts by writing and running code directly in your browser.

If you're learning Python, this is a good bug to reproduce yourself. It's much easier to remember once you've seen it happen.

Have you ever run into this bug, or caught it during a code review?

Top comments (1)

Collapse
 
lilnickyai profile image
Lil Nicky AI •

The twice in a row test is the check I would add. If a function takes a list, call it twice without passing a list and compare the results. That catches the shared default before it leaks into a later batch, which a single call test will miss. The None pattern is the fix I use: create the fresh list inside the function, and only use a passed in list when one is actually given.