Back to Blog
Python

Python Default Arguments: Mutable Pitfalls

python default arguments: Understand how Python evaluates default arguments, why mutable defaults cause bugs, and how to use the None sentinel pattern.

pythondefault-argumentsmutable-defaultsfunction-definitionnone-sentinel
Illustration of a Python function definition with a mutable default list causing shared state across calls.

Python default arguments are evaluated once, at function definition time, not on every call. This behavior is the root of one of the most common Python bugs: using a mutable object like a list or dictionary as a default value. Understanding when defaults are evaluated is essential for writing functions that behave predictably across calls.

Default Arguments Are Evaluated at Definition Time

When you define a function, Python executes the def statement and evaluates each default expression in the current scope. The resulting objects are stored as attributes of the function object. They are not re-evaluated when the function is called. This means a default like def f(x=[]) creates one list, and that same list is used for every call that omits x.

def add_item(item, container=[]): container.append(item) return container print(add_item(1)) # [1] print(add_item(2)) # [1, 2]

The second call does not start with a fresh list. It reuses the list created during the def statement. This is surprising to many developers because most other languages evaluate default parameters at call time.

The Mutable Default Trap

The most visible consequence is shared mutable state. If the default is a list, dictionary, set, or any object that can be modified in place, changes persist across calls. This often leads to bugs that are hard to trace because the function appears to "remember" data from previous invocations.

def add_to_dict(key, value, cache={}): cache[key] = value return cache print(add_to_dict('a', 1)) # {'a': 1} print(add_to_dict('b', 2)) # {'a': 1, 'b': 2}

This behavior is not accidental. It is a direct consequence of evaluating defaults once. While the language documentation warns against mutable defaults, the mechanism itself is consistent and predictable once you understand it.

Why Python Evaluates Defaults Once

Python's function definitions are executable statements. The def keyword binds a name to a function object, and part of that construction involves evaluating the default expressions. This design allows defaults to be computed from variables that exist at definition time, which can be useful for configuration values that should be fixed for the lifetime of the function.

import os def get_config(timeout=os.environ.get('TIMEOUT', '30')): return timeout

The default is read from the environment once, when the module is imported. Subsequent calls to get_config() return the same string, even if the environment variable changes later. This is a deliberate tradeoff: defaults are static, not dynamic.

The None Sentinel Pattern

The standard fix for mutable defaults is to use None as the default and create a fresh object inside the function body when the argument is omitted.

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

Now each call without an explicit container gets a new list. This is idiomatic Python and is recommended in the official style guide. The None sentinel is safe because None is immutable and cannot be modified in place. The check if container is None is fast and unambiguous.

This pattern also works for dictionaries, sets, and any other mutable type. It makes the function's behavior explicit and avoids surprising shared state.

When Immutable Defaults Still Cause Surprises

Immutable defaults like integers, strings, and tuples are safe from the mutation problem, but they are still evaluated only once. This can cause subtle issues when the default value is derived from a function call or an object that is not truly immutable.

import datetime def log_time(timestamp=datetime.datetime.now()): return timestamp print(log_time()) # time of definition print(log_time()) # same time again

Because datetime.datetime.now() is evaluated at definition time, every call returns the same timestamp. If the intent was to capture the call time, this is a bug. The same issue appears with random.random(), time.time(), or any call that should produce a fresh value per invocation. The solution is the same: use None and compute the value inside the function.

Using Defaults for Caching and Configuration

Sometimes shared mutable defaults are used deliberately, for example to cache results across calls. While this works, it is fragile because the cache is tied to the function object and is not easily cleared or inspected. A more explicit approach is to use a module-level variable or a class attribute.

_cache = {} def fetch(key): if key not in _cache: _cache[key] = compute_value(key) return _cache[key]

This makes the cache visible and controllable. If you really want a per-function cache, you can store it as an attribute on the function itself, but that is rarely worth the added complexity. The None sentinel pattern remains the safest default for most functions.

Maintainability and Code Review Considerations

Mutable default arguments are a common code review finding. Even if the current implementation does not mutate the default, future changes might. Using None as a sentinel is a simple, well-understood convention that prevents a whole class of bugs. It also makes the function's contract clearer: an omitted argument is distinct from an explicit None, which can be useful when None is a valid value.

def process(data, config=None): if config is None: config = default_config() # ...

This pattern is easy to read and maintain. It also avoids the need to remember that default expressions are evaluated once. When reviewing code, flag any mutable default and suggest the sentinel pattern. The fix is small, but it eliminates a subtle source of runtime errors that are difficult to reproduce in isolation.

Advanced: Using a Custom Sentinel

In rare cases, None may be a legitimate argument value, and you need to distinguish between "not provided" and "explicitly None". You can define a unique sentinel object and use it as the default.

_MISSING = object() def register(name, value=_MISSING): if value is _MISSING: value = generate_default() # ...

This is more verbose but gives you full control. The sentinel object is created once at module level and is immutable. This pattern is useful in APIs where None has a specific meaning and you need a third state. It is a natural extension of the None sentinel idea and follows the same principle: avoid mutable defaults, and evaluate fallback values at call time.

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