Here's a Python function that looks completely normal:
def add_tag(tag, tags=[]): tags.append(tag) return tags 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")) Output:
['urgent'] ['urgent', 'billing'] ['urgent', 'billing', 'refund'] 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__) Output:
([],) (['urgent', 'billing'],) 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 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") You might expect two different timestamps.
But the output looks like this:
[10:15:03] job started [10:15:03] job finished 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 Let's process Monday's orders:
monday = [ {"id": 1, "amount": 90}, {"id": 2, "amount": None} ] print("Monday:", validate_orders(monday)) Output:
Monday: ['order 2: missing amount'] 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)) Output:
Tuesday: ['order 2: missing amount'] 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")) Output:
['urgent'] ['billing'] 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) Output:
['important', 'review'] 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}") 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 And it appears to work.
Until someone passes an empty list intentionally.
my_tags = [] add_tag("urgent", my_tags) print(my_tags) Output:
[] Wait, where did "urgent" go?
The function did append it, but not to my_tags.
Here's the problem:
tags = tags or [] 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 = [] 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] = [] 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) Now let's create two tickets.
first = Ticket() second = Ticket() first.tags.append("urgent") print(first.tags) print(second.tags) Output:
['urgent'] [] 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
B006catches mutable argument defaults liketags=[]. - Ruff
B008flags function calls in default arguments, such asdatetime.now(). - Pylint
W0102warns about dangerous mutable defaults.
If you're using Ruff, you can check for both of these patterns with:
ruff check --select B006,B008 . 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}" 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=[]): 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__) 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?



