PYTHON / FUNCTIONS
Default parameter values and the mutable default trap
Predict and control how Python evaluates default parameter values, and fix functions that share one mutable default across every call.
What you will learn
- Explain that defaults are evaluated once, when the def statement executes
- Spot the shared-list bug by calling a function twice with no arguments
- Rewrite a mutable default using the None sentinel pattern correctly
- Use a private sentinel object when None is itself a valid argument value
Understanding Default parameter values and the mutable default trap
A default value is an ordinary expression that Python evaluates while it builds the function object, not while it calls it. The resulting objects are stored in a tuple on the function itself, reachable as func.__defaults__, and every call that omits the argument binds the parameter to that same stored object. So `def f(n=2+3)` stores the integer 5 once, and `def f(t=time.time())` stores one timestamp forever, frozen at import time.
That storage rule is harmless for immutable defaults and dangerous for mutable ones. If the default is a list, dict, or set, the parameter name points at one long-lived object, and `tags.append(tag)` mutates it in place. The change survives the call because the function object, not the call frame, owns the list. Callers then see leftovers from calls they never made, and the bug usually surfaces late, because the first call looks perfectly correct.
The standard repair is to store an immutable marker instead of the container: default the parameter to None and build a fresh list inside the body when it is None. The test must be `if tags is None`, not `if not tags`, because an empty list supplied by the caller is falsy but is still the caller's object, and replacing it silently throws away their appends. When None is a meaningful value the caller might pass, create a module-level sentinel with `object()` and compare against that instead.
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
print(add_tag('python'))
print(add_tag('beginner'))
print(add_tag('draft', []))
print(add_tag.__defaults__)Default values are evaluated once at function definition time, so a mutable default is one object shared by every call that omits it.
Worked examples
The default expression runs exactly once
Shows that a function call used as a default is evaluated at definition time, not per call.
counter = 0
def next_id():
global counter
counter += 1
return counter
def log(msg, request_id=next_id()):
return f'{request_id}: {msg}'
print(log('start'))
print(log('stop'))
print('next_id calls:', counter)Example explained
Line 1next_id() runs while the `def log` statement executes, so counter reaches 1 before any call to log.
Line 2Both log() calls reuse the stored integer 1; the default expression is never re-evaluated.
Line 3counter stays at 1, proving the call count of next_id is tied to definitions, not invocations.
Line 4The bug is invisible with an int, but the same timing would freeze a timestamp or a config snapshot.
The None sentinel fix
Builds a new list per call while still letting the caller pass their own list to extend.
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tags
print(add_tag('python'))
print(add_tag('beginner'))
existing = ['old']
print(add_tag('new', existing))
print(existing)Example explained
Line 1None is immutable, so the stored default cannot accumulate anything between calls.
Line 2`tags = []` inside the body runs on every defaulted call, producing an independent list each time.
Line 3The second call now returns ['beginner'] alone, with no residue from the first.
Line 4When a list is passed in, the function still mutates it in place, which is why `existing` also shows 'new'.
When None is a legal argument
Uses a private sentinel object to distinguish 'argument omitted' from 'argument was None'.
_MISSING = object()
def get(config, key, default=_MISSING):
if key in config:
return config[key]
if default is _MISSING:
raise KeyError(key)
return default
cfg = {'host': 'localhost', 'proxy': None}
print(get(cfg, 'host'))
print(get(cfg, 'proxy', 'unset'))
print(get(cfg, 'port', None))
try:
get(cfg, 'port')
except KeyError as err:
print('missing:', err)Example explained
Line 1_MISSING is a unique object created once, so no caller can accidentally pass a value equal to it.
Line 2get(cfg, 'proxy', 'unset') returns None because the key exists and its stored value is None.
Line 3get(cfg, 'port', None) returns None as a deliberate fallback, which a None default could not express.
Line 4Omitting the argument leaves default identical to _MISSING, so the function raises instead of guessing.
Important notes
Immutable defaults such as 0, '', None, and tuples are safe because nothing can change them in place; the trap is specifically about in-place mutation of a shared object.
Returning `tags + [tag]` instead of appending also avoids the trap, but it builds a new list on every call and no longer mutates a list the caller passed in.
Common mistakes
Assuming `tags=[]` creates a new list on every call: results silently accumulate across unrelated calls, and the function appears to remember data the caller never gave it.
Writing `if not tags: tags = []` instead of `if tags is None`: a caller who passes an existing empty list gets it replaced, so their list stays empty and the appended items vanish.
Storing a mutable default on a class method, such as `def __init__(self, items={})`: every instance created without that argument shares one dict, so instances start seeing each other's data.
Try it yourself
Change, predict, then run
Write `def collect(word, seen={})` that stores each word as a key with its length as the value and returns the dict, then call it three times with different words to watch the dict grow. Rewrite it with the None sentinel so each defaulted call returns a one-entry dict.
Open the Python workspaceCheck your understanding
A function is defined as `def push(item, stack=[])` and called four times without ever passing stack. Why does the returned list keep growing?
- The list object was created once when the def statement ran, so every defaulted call mutates that same object
- Python caches return values of functions that have default arguments and replays the earlier results
- A list written in a parameter list becomes a global variable, so the function sees module state
- The list is rebuilt on each call, but Python copies the previous contents into the new list first
Show answer
The default expression is evaluated once at definition time and the resulting list is stored on the function object, so append mutates one shared list forever. Option 3 is tempting because the observed behaviour looks like carried-over contents, but nothing is copied and no new list is ever built; you can prove it by checking that push.__defaults__[0] is the very list being returned.