Default Arguments

Default values make parameters optional — but they're computed only once, at definition time, which is the source of Python's most infamous beginner trap.

A default argument gives a parameter a fallback value used whenever the caller omits it: def f(x=10): .... The mechanics look trivial, but *when* that default value is evaluated — once, at function-definition time, not on every call — has real consequences, especially when the default is a mutable object like a list or dict. Understanding this timing is the difference between a convenient shortcut and a subtle, hard-to-diagnose bug.

What Is a Default Argument

Writing name=value in a parameter list supplies a default: if the caller doesn't pass that argument, the parameter is bound to value instead.

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Amara"))              # Hello, Amara!
print(greet("Amara", "Hi"))        # Hi, Amara!
print(greet("Amara", greeting="Yo"))  # Yo, Amara!

Default Evaluation Time

A default value's expression is evaluated exactly once — when the def statement runs and creates the function object — not each time the function is called. The resulting object is then stored on the function and reused for every call that omits that argument.

This surprises many newcomers who expect the default to be re-evaluated fresh on every call, like it would be if it were the first line inside the function body.

import time

def log(message, timestamp=time.time()):
    return f"[{timestamp}] {message}"

print(log("first"))
time.sleep(1)
print(log("second"))
# Both calls print the SAME timestamp — it was captured once, when log was defined

The Mutable Default Argument Trap

Because the default object is created once and reused across calls, a *mutable* default (a list, dict, or set) is shared and persists between calls. If the function body mutates it in place, that mutation carries over to the next call — even for a different, unrelated invocation.

Every call that doesn't supply cart explicitly is mutating the *same* list object that was created once at definition time — there is no fresh empty list per call.

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

print(add_item("apple"))   # ['apple']
print(add_item("banana"))  # ['apple', 'banana']  <- unexpected! same list object
print(add_item("pear"))    # ['apple', 'banana', 'pear']

Safe Pattern: Sentinel Values with None

The standard fix is to default to the immutable singleton None, then create a fresh mutable object inside the function body on each call if the caller didn't supply one. This is the idiomatic 'sentinel value' pattern.

Always check with is None, not falsiness (if not cart:) — an empty list the caller explicitly passed in ([]) is falsy too, and you don't want to silently replace it.

def add_item(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart

print(add_item("apple"))   # ['apple']
print(add_item("banana"))  # ['banana']  <- correct, fresh list each time

shared = ["eggs"]
print(add_item("milk", shared))  # ['eggs', 'milk'] — explicit list still works normally

Sentinel Values Beyond None

None is the common sentinel, but sometimes None is itself a meaningful value the caller might legitimately want to pass. In that case, a unique private sentinel object distinguishes 'not supplied' from 'explicitly passed None'.

_MISSING = object()  # a unique object no caller could ever pass by accident

def configure(value=_MISSING):
    if value is _MISSING:
        return "using default configuration"
    return f"using explicit value: {value!r}"

print(configure())        # using default configuration
print(configure(None))    # using explicit value: None  <- distinguishable!

Immutable Defaults Are Safe

The mutable-default trap does not apply to immutable defaults (numbers, strings, tuples, frozensets, None, booleans) — since they can't be mutated in place, reusing the same object across calls is harmless.

def repeat(text, times=3, sep=", "):
    return sep.join([text] * times)

print(repeat("hi"))         # hi, hi, hi
print(repeat("yo", 2, "-")) # yo-yo
# times and sep are int/str — immutable, safe to reuse as defaults

Practical Usage

Default arguments are the standard way to give a function sensible fallback behavior while still letting advanced callers override it — configuration functions, optional feature flags, and API wrappers all lean on this heavily.

def fetch_data(url, *, timeout=30, retries=3, headers=None):
    headers = headers or {}
    # ... perform request with sensible defaults, override only what's needed
    return f"GET {url} timeout={timeout} retries={retries} headers={headers}"

print(fetch_data("https://api.example.com"))
print(fetch_data("https://api.example.com", timeout=5))

Edge Cases

A default expression can reference other names available at definition time (module-level globals, or earlier parameters in some contexts is NOT allowed — parameters cannot reference each other in a default expression).

Reassigning the function's __defaults__ attribute directly is possible but rare and considered poor style — it bypasses the readable def syntax entirely.

def f(a, b=a):   # NameError: name 'a' is not defined
    return a, b
# Default expressions are evaluated in the enclosing scope at def time,
# NOT in the function's own local scope — 'a' as a parameter doesn't exist yet.

Complete Example

Each call gets its own independent 'sections' list because of the None-sentinel pattern — report1 and report2 never share state.

def build_report(title, *, sections=None, footer="End of report"):
    """Build a text report; sections defaults to a fresh list per call."""
    if sections is None:
        sections = []
    lines = [f"=== {title} ==="]
    lines.extend(sections)
    lines.append(footer)
    return "\n".join(lines)

report1 = build_report("Sales")
report2 = build_report("Inventory", sections=["Item A: 10", "Item B: 5"])
print(report1)
print(report2)

Mental Model

Picture the def statement as running once and stamping the default values onto the function object itself, like a label. Every call that omits that argument just reads the same label off the same function object — it never re-runs the expression that created the label.

Quick Reference

Safe defaults:        numbers, strings, tuples, None, bool, frozenset
Unsafe as-is defaults: list, dict, set, and other mutable objects

Fix pattern:
    def f(x=None):
        if x is None:
            x = []   # fresh object created on every call

Key Takeaways

Default values are evaluated exactly once, at function-definition time — not on every call.

A mutable default (list, dict, set) is shared across all calls that don't override it, causing state to leak between unrelated calls.

The standard fix is to default to None and create the real mutable object inside the function body.

Check for the sentinel with is None, never with plain falsiness, to avoid overwriting a legitimately empty argument.

Immutable defaults (numbers, strings, tuples) are always safe to reuse as-is.

def add_item(item, cart=[]): cart.append(item) return cart def add_item(item, cart=None): if cart is None: cart = [] cart.append(item) return cart

The mutable list default is created once and shared across every call that doesn't pass its own cart, so items silently accumulate across unrelated calls.

def configure(flag=False): if not flag: flag = get_default_flag()

_MISSING = object() def configure(flag=_MISSING): if flag is _MISSING: flag = get_default_flag()

Checking falsiness treats an explicitly passed False the same as 'not supplied', silently overriding a caller's real choice; a sentinel distinguishes the two cases.

def log(msg, ts=datetime.now()): print(ts, msg) def log(msg, ts=None): ts = ts or datetime.now() print(ts, msg) datetime.now() runs once at definition time, so every call without an explicit ts logs the same frozen timestamp instead of the current time.

When is a default argument's value expression evaluated?

Exactly once, when the def statement executes and the function object is created — not on each individual call.

Why does a mutable default argument like cart=[] cause bugs?

Because the same list object is reused across every call that omits the argument; mutating it in one call (e.g. append) leaves the mutation visible on the next call too.

What is the idiomatic fix for a function that needs a fresh empty list by default?

Default the parameter to None, then inside the function body check 'if x is None: x = []' to create a brand-new list on every call that needs one.

Why should you check 'if x is None' rather than 'if not x' when applying the sentinel pattern?

Because 'if not x' is also true for a legitimately empty but explicitly passed argument (like [] or 0), which would incorrectly replace the caller's real value with the default.

Is it safe to use a tuple as a default argument value? Why or why not?

Yes — tuples are immutable, so even though the same tuple object is reused across calls, nothing can mutate it in place, so there's no shared-state bug.

What happens if you write def f(a, b=a): ...?

It raises a NameError, because default expressions are evaluated in the enclosing scope at definition time, and the parameter 'a' does not exist as a name in that scope yet.

Give a scenario where None itself is a valid argument value, and explain how you'd still detect 'not supplied'.

A configure(value=None) function where None is a meaningful setting — use a unique sentinel object (e.g. _MISSING = object()) as the default instead of None, and compare with 'is _MISSING'.

Write a function append_log(entry, log=None) that returns a list with entry appended, ensuring each call without an explicit log gets an independent list.

def append_log(entry, log=None): if log is None: log = [] log.append(entry) return log print(append_log("start")) print(append_log("stop"))

['start'] then ['stop'] — each call creates its own fresh list since log defaults to None, not a shared list.

What does this print?

[1] then [1, 2] then [1, 2, 3] — the same default list object is reused and mutated across all three calls.

Write a function make_counter(start=0, step=1) that returns a closure incrementing by step each call, demonstrating that immutable int defaults are safe to reuse.

def make_counter(start=0, step=1): count = start def counter(): nonlocal count count += step return count return counter c1 = make_counter() c2 = make_counter(100, 10) print(c1(), c1(), c2(), c2())

1 2 110 120 — start and step are ints (immutable), so reusing the default 0 and 1 across every make_counter() call with no arguments is completely safe.

'default' then None — the sentinel object correctly distinguishes 'no argument passed' from 'None explicitly passed'.

def f(x, items=[]):
    items.append(x)
    return items

print(f(1))
print(f(2))
print(f(3))
_MISSING = object()
def f(x=_MISSING):
    return "default" if x is _MISSING else x

print(f())
print(f(None))