Python Function Arguments: Defaults, Keywords and the Mutable Default Trap
Defaults make functions easier to call and keywords make calls easier to read. A list as a default value is created once and shared by every call. Here's why, and the one-line fix.
Default arguments make functions easier to call. Keyword arguments make calls easier to read. And one default value, the empty list, causes a bug that has confused Python programmers for years. All three are worth five minutes.
Skip ahead: If you only came for the bug, go to "The mutable default trap". The fix is one line.
Positional and keyword arguments
def make_label(name, price, currency="PHP"):
return f"{name}: {currency} {price:,.2f}"
print(make_label("Coffee", 95))
print(make_label("Coffee", 95, "USD"))
print(make_label(price=95, name="Coffee"))
print(make_label("Coffee", price=95, currency="USD"))
Coffee: PHP 95.00
Coffee: USD 95.00
Coffee: PHP 95.00
Coffee: USD 95.00
- Positional: the value goes to the parameter in the same position.
"Coffee"isname,95isprice. - Keyword: you name the parameter, so the order doesn't matter.
- Default:
currency="PHP"is used when you don't pass a currency.
You can mix them, but positional ones come first. make_label(name="Coffee", 95) is a SyntaxError.
When to use keyword arguments
Look at these two calls:
send_report("sales.csv", True, False, 3)
send_report("sales.csv", compress=True, overwrite=False, retries=3)
They do the same thing. Only the second one tells you what True, False and 3 mean without opening the function. My rule: if an argument is a plain True/False or a number whose meaning isn't obvious, pass it by keyword.
You can even force it. Everything after a * must be passed by keyword:
def send_report(path, *, compress=False, overwrite=False, retries=1):
return f"{path} compress={compress} overwrite={overwrite} retries={retries}"
print(send_report("sales.csv", compress=True))
try:
send_report("sales.csv", True)
except TypeError as error:
print("TypeError:", error)
sales.csv compress=True overwrite=False retries=1
TypeError: send_report() takes 1 positional argument but 2 were given
The mutable default trap
Here's a function that adds an item to a list, and starts a new list when you don't pass one:
def add_item(item, cart=[]):
cart.append(item)
return cart
print(add_item("coffee"))
print(add_item("bread"))
print(add_item("milk"))
['coffee']
['coffee', 'bread']
['coffee', 'bread', 'milk']
Each call was supposed to start with an empty cart. Instead, the cart keeps growing. A second customer gets the first customer's coffee.
Why it happens
The default value is created once, when Python reads the def line. Not every time you call the function. So there's one list, stored with the function, and every call that uses the default gets that same list. append changes it, and the change stays.
You can see the list sitting there:
def add_item(item, cart=[]):
cart.append(item)
return cart
add_item("coffee")
add_item("bread")
print(add_item.__defaults__)
(['coffee', 'bread'],)
This only bites with values you can change in place: lists, dictionaries and sets. Numbers, text, True/False and None can't be changed, so a default like currency="PHP" or retries=1 is always safe.
The fix
Use None as the default, and create the new list inside the function:
def add_item(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cart
print(add_item("coffee"))
print(add_item("bread"))
shared = ["rice"]
print(add_item("eggs", shared))
print(shared)
['coffee']
['bread']
['rice', 'eggs']
['rice', 'eggs']
Now cart = [] runs on every call that didn't pass a cart, so each one gets its own new list. Passing your own list still works, and it's still changed in place, which is what the caller asked for.
⚠ Warning: Write if cart is None:, not if not cart:. With not cart, a caller who passes their own empty list gets a different new list back, and their list stays empty. That's a quieter version of the same kind of bug.
The same applies to dictionaries and sets: options=None, then if options is None: options = {}.
💡 Tip: Code checkers like Pylint and Ruff warn about mutable defaults (for example "dangerous default value"). If your editor shows that warning, it's this bug.
The same problem with time
The "created once" rule applies to every default, not just lists:
import time
from datetime import datetime
def log(message, when=datetime.now()):
return when
first = log("start")
time.sleep(1.1)
second = log("later")
print(first == second)
True
Both calls return the same time, the moment the function was defined. Same fix: when=None, then if when is None: when = datetime.now().
Common mistakes
- **
def f(items=[])ordef f(options={}).** UseNoneand create it inside. - **
if not items:instead ofif items is None:.** It treats an empty list passed by the caller as "nothing passed". - **Long rows of positional
True/False.** Use keywords, or force them with*. - Putting a default parameter before a required one.
def f(a=1, b)is aSyntaxError. Required parameters come first.
What happens if you skip the fix
Tests often pass, because each test starts a fresh program. The bug shows up in a long-running program, like a web app, where the function is called hundreds of times by different people. Data from one request shows up in another. It's hard to find, because the function looks correct when you read it.
If a web app used add_item(item, cart=[]) for every visitor's cart, whose items would the hundredth visitor see?
About
Positional, keyword and default arguments, keyword-only parameters, and why def f(items=[]) leaks data between calls. After this, you can spot the mutable default trap and fix it with None.
- Responsible
- John G
- Last Update
- 09/16/2026
- Reading Time
- 5 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.