Back to Blog
Python

Python Default Parameters: Avoiding Mutable Defaults

python default parameters: Understand how Python evaluates default parameters at definition time and why mutable defaults cause shared state. Learn the None sentinel p...

pythonfunction argumentsmutable defaultcode qualitypython pitfalls
Illustration of Python default parameter evaluation showing a list being shared across function calls.

python default parameters requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

In Python, default parameter values are evaluated once when the function is defined, not each time the function is called. This behavior is the root cause of a common bug that surprises many developers: mutable default arguments that retain state across calls. Understanding this evaluation timing is essential for writing predictable functions.

How Python Evaluates Default Parameters

When you define a function with a default value, Python evaluates that expression at definition time and stores the resulting object as part of the function object. Consider:

def append_to(item, target=[]): target.append(item) return target

The list [] is created once, when the def statement executes. Every call that omits target receives the same list object. The first call returns [1], the second returns [1, 2], and so on. This is not a bug in the language; it is a direct consequence of the evaluation timing.

The Mutable Default Argument Problem

The problem appears when the default value is mutable, such as a list, dictionary, or set. Because the default is shared, any mutation inside the function persists across calls. This can lead to subtle state leakage and makes the function's behavior depend on call history.

def add_item(item, cache={}): cache[item] = True return cache

Each call without a cache argument adds to the same dictionary. If the function is used as a cache, this might be intentional, but for most functions it is unexpected and can cause hard-to-debug issues.

Why Evaluation Happens at Definition Time

Python treats default values as part of the function's signature. The def statement is an executable statement, and the default expressions are evaluated in the surrounding scope at that moment. This is similar to how class bodies execute at class definition time. The language designers chose this for performance: the default object is created once and reused, avoiding repeated allocation on every call. For immutable types like integers, strings, or tuples, this is harmless because they cannot be changed. The problem only arises with mutable objects.

Standard Workaround: Use an Immutable Sentinel

The conventional fix is to use None as the default and assign a fresh mutable object inside the function when the argument is not supplied.

def append_to(item, target=None): if target is None: target = [] target.append(item) return target

Now each call without target creates a new list. This pattern is idiomatic and appears throughout the standard library. It works because None is immutable and the check is cheap. The same approach applies to dictionaries, sets, and any other mutable default.

When Mutable Defaults Are Intentional

There are rare cases where a shared mutable default is deliberate. For example, a function that maintains a registry or a cache across calls without using a class or global variable. However, this is almost always better expressed with an explicit object, such as a class attribute or a module-level container. Relying on the default argument for state makes the behavior implicit and harder to test, because the state is tied to the function object itself. If you need persistent state, consider a class with an __init__ method or a closure with a mutable variable.

Type Hints and Default Parameters

When you use None as a sentinel, type hints become slightly more involved. The parameter type should be Optional[list] or list | None (Python 3.10+), and you need to narrow the type inside the function.

from typing import Optional def append_to(item: int, target: Optional[list[int]] = None) -> list[int]: if target is None: target = [] target.append(item) return target

This makes the intent clear and allows static type checkers to verify that target is a list after the None check. The same pattern applies to any mutable type.

Practical Considerations for Production Code

The mutable default issue is a common source of bugs, but it is also a symptom of a deeper design question: when should a function own mutable state? In production code, prefer functions that are pure or have explicit state management. If a function needs a mutable accumulator, either require the caller to pass it in or use a class. Avoid hidden state that changes the function's behavior across calls. This improves testability and makes the code easier to reason about. When reviewing code, look for mutable default arguments and question whether the behavior is intentional. The None sentinel pattern is the standard way to provide a fresh mutable object per call without complicating the signature.

python default parameters: Practical Usage and Code Examples | RYUSLOG DEV