SECTION 1
Historical Context & Motivation
When programmers write functions, they often want some parameters to have a "standard" value that kicks in when the caller doesn't supply one. Before languages supported default arguments, developers had to write repetitive code that checked whether each value was provided and then manually assigned fallbacks. This was tedious, error-prone, and cluttered the logic of the function itself.
The idea of letting the function signature declare a fallback value dates back to early programming languages, but Python's approach—introduced right from version 1.0 in 1994—brought both elegance and a famous trap. Because Python evaluates default values only once, at the moment the function is defined rather than each time it is called, mutable defaults like lists and dictionaries can behave in surprising ways. Understanding this quirk is a rite of passage for every Python programmer.
1991 • Python 0.9 Released
1994 • Python 1.0 — Default Arguments Arrive
2001 • PEP 8 & Community Awareness
2008 • Python 3.0 Released
So the central question this lesson addresses is: How do default arguments work under the hood, and what happens when the default value is something that can change—like a list?
SECTION 2
Core Principles & Definitions
Before diving into code, you need to grasp a handful of foundational ideas. A default argument is a value you assign to a parameter in the function definition itself, using an equals sign. When the caller omits that argument, the default kicks in automatically. Meanwhile, an immutable object is something that cannot be changed after creation (like a number, string, or tuple), whereas a mutable object can be modified in place (like a list, dictionary, or set).
Default Argument
def greet(name="World"). If no name is passed, the function uses "World".Definition-Time Evaluation
def statement runs, not each time the function is called.Mutable vs. Immutable
The None Sentinel Pattern
None, then create a fresh mutable object inside the function body if the argument is None.KEY TAKEAWAY
None as the sticky note and hand out a new notebook inside the function.SECTION 3
Visual Explanation — How Defaults Are Stored
The diagram below illustrates what happens in memory when Python processes a def statement with a mutable default. Notice that the default list object is created once and stored inside the function object. Every subsequent call that relies on the default points to the exact same list.
__defaults__ tuple holds a single list object. Call 1 appends 'a' to it; Call 2 finds ['a'] already there and appends 'b'. The result is ['a', 'b']—not ['b'] as you might expect.This is the crux of the mutable default pitfall. The diagram makes it clear that the list is not recreated on each call—it's a single object that persists for the lifetime of the function. If the default were an immutable value like 0 or "hello", this wouldn't matter because you can't modify those objects in place.
SECTION 4
How Python Handles Defaults Under the Hood
The def Statement Is Executable
In Python, def is not just a declaration—it's an executable statement. When the interpreter reaches a def line, it creates a new function object and evaluates all default-value expressions immediately. The resulting objects are stored in a tuple attached to the function as the attribute __defaults__. This happens exactly once, no matter how many times the function is later called.
Step-by-Step Evaluation Flow
- Step 1 — Encounter
def: Python reads the function header and evaluates each default expression left-to-right. - Step 2 — Store defaults: The evaluated objects are packed into
func.__defaults__. - Step 3 — Call without argument: Python looks up the corresponding default from
__defaults__and binds it to the parameter name. - Step 4 — Mutate?: If the function modifies the default object in place (e.g.,
lst.append(x)), the change persists across future calls.
Inspecting Defaults in the REPL
You can actually see the stored defaults at any time. After defining a function like def f(x, items=[]): and calling f.__defaults__ you'll get ([],) the first time, and if you call f(5) a few times while the function appends to items, you'll see the list growing inside __defaults__. This makes the behavior concrete and observable.
Why Didn't Python Fix This?
None as a sentinel when you want a fresh mutable object each time.SECTION 5
Safe vs. Unsafe Default Types
Not every default is dangerous. The key question is whether the default object can be modified in place. The following diagram groups Python's common types into safe (immutable) and unsafe (mutable) categories, along with the recommended fix for each unsafe type.
| Type | Mutable? | Safe as Default? | Recommended Default |
|---|---|---|---|
int, float, bool | No | ✓ Yes | Use directly, e.g. 0, 1.0 |
str | No | ✓ Yes | Use directly, e.g. "hello" |
tuple, frozenset | No | ✓ Yes | Use directly |
list | Yes | ✗ No | Use None, create inside |
dict | Yes | ✗ No | Use None, create inside |
set | Yes | ✗ No | Use None, create inside |
Quick-reference table: which types are safe as defaults?
SECTION 6
Worked Example — The Bug and The Fix
Let's walk through a realistic example. Imagine you're building a simple logging system where a function collects messages into a list.
Buggy Version: Mutable Default List
- 1
Step 1 — Define the function with a mutable default
We write:def log(msg, messages=[]):followed bymessages.append(msg)andreturn messages. The empty list[]is evaluated once when Python processes thedefstatement. - 2
Step 2 — First call
We calllog("Error A"). Since we didn't passmessages, Python binds it to the default list. The function appends"Error A"and returns the list.Returns:["Error A"]← looks correct! - 3
Step 3 — Second call reveals the bug
We calllog("Error B"). Again,messagesbinds to the same default list—which already contains"Error A"from the previous call. After appending, the list now has both items.Returns:["Error A", "Error B"]← BUG! We expected["Error B"] - 4
Step 4 — Apply the None sentinel fix
Rewrite the function:def log(msg, messages=None):then inside the body:if messages is None: messages = []. Now each call that doesn't passmessagesgets a brand-new empty list. - 5
Step 5 — Verify the fix
Callinglog("Error A")returns["Error A"]. Callinglog("Error B")now returns["Error B"]. Each call starts fresh, and callers who pass their own list still get the expected behavior.Both calls now return independent lists. ✓
SECTION 7
Strengths, Pitfalls, and Trade-offs
Default arguments are one of Python's most convenient features, but they come with trade-offs. The table below lays out the advantages alongside the common pitfalls so you can use defaults confidently.
| Strengths | Pitfalls |
|---|---|
| Simplify function calls — callers can skip arguments that usually stay the same. | Mutable defaults (list, dict, set) are shared across calls, leading to unexpected data leakage. |
| Make APIs self-documenting — the signature shows what the "normal" values are. | Definition-time evaluation can surprise beginners who expect call-time evaluation. |
| Allow one function to serve many use cases without overloading. | Overusing defaults can make function signatures hard to read if there are many parameters. |
| The None sentinel pattern is easy to learn and universally understood. | Forgetting the is None check leads to subtle bugs that may only appear after many calls. |
Default arguments: strengths vs. pitfalls
RULE OF THUMB
None to give each caller their own brand-new charger. In practice, this means any time your default is [], {}, or set(), replace it with None and initialize inside the function.SECTION 8
Connection to Advanced Concepts
The mutable default pattern is your first encounter with a broader theme in Python: objects vs. names. In Python, variables are labels that point to objects, not boxes that contain values. Understanding this distinction prepares you for more advanced topics like closures, decorators, and class-level attributes.
| This Lesson | Advanced Concept |
|---|---|
| Default arguments evaluated at definition time | Closures — inner functions capture variables from their enclosing scope at definition time, a similar "snapshot" idea. |
| Using None as a sentinel | Sentinel objects — libraries sometimes create custom sentinel objects (e.g., _MISSING = object()) for more nuanced defaults. |
| Shared mutable state across calls | Class attributes vs. instance attributes — a class-level list is shared by all instances, the same pattern as a mutable default. |
| Immutable defaults are safe | Functional programming — favoring immutable data eliminates a whole class of bugs, a principle you'll see in languages like Haskell and Rust. |
How this lesson connects to advanced Python topics
As you progress, you'll also encounter type hints (e.g., def f(items: list[int] | None = None) -> list[int]:) that make the None-sentinel pattern even clearer to readers and to static analysis tools like mypy. For now, the core skill is recognizing when a default is mutable and applying the fix.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
Show answer
Question 2: BASIC CALCULATION
def add_score(score, scores=[]):
scores.append(score)
return scores
What does add_score(10) return the first time? What does add_score(20) return the second time? Explain why.Show answer
add_score(10) returns [10]. The second call add_score(20) returns [10, 20], not [20]. This happens because the default list [] was created once when the function was defined. Both calls use the same list object, so the first call's append persists when the second call runs.Question 3: INTERMEDIATE
def register_student(name, roster=[]):
roster.append(name)
return roster
Your fixed version should return a list containing only the current call's student when no roster is provided, yet still accept an existing roster if one is passed.Show answer
def register_student(name, roster=None): followed by if roster is None: roster = [] and then roster.append(name); return roster. Now, when no roster is passed, the function creates a fresh empty list on each call. If a caller does pass an existing list, it is used directly, preserving the function's flexibility. The key change is replacing [] with None in the parameter and guarding with is None inside the body.Question 4: APPLIED
def create_profile(username, settings={}):
settings['theme'] = settings.get('theme', 'light')
return {'user': username, 'settings': settings}
A tester calls create_profile('Alice') and then create_profile('Bob'). Describe the bug that occurs and write a corrected version.Show answer
{} is shared across calls. After Alice's call, the default dict becomes {'theme': 'light'}. When Bob's call runs, it reuses this same dict. Both profiles end up sharing the same settings object, so changing Bob's theme would also change Alice's. The fix: def create_profile(username, settings=None): then if settings is None: settings = {} inside the body, followed by the rest of the logic. Each call now gets an independent settings dictionary.Question 5: CRITICAL THINKING
def fibonacci(n, cache={}):
if n in cache:
return cache[n]
if n <= 1:
return n
result = fibonacci(n-1, cache) + fibonacci(n-2, cache)
cache[n] = result
return result
Explain how this function uses the mutable default intentionally and why it makes the function faster. When would this approach be appropriate, and when should you avoid it?Show answer
cache survives between calls, previously computed Fibonacci numbers are remembered, turning an exponential-time algorithm into a linear-time one. It's appropriate for pure functions where caching is desired and the developer is fully aware of the shared state. However, this approach should be avoided in multi-threaded programs (risk of race conditions), when memory growth is a concern (the cache grows without bound), or when the shared state would confuse other developers. In production code, the @functools.lru_cache decorator is usually preferred because it's explicit and provides cache-size controls.SUMMARY
Lesson Summary
Summary
Default arguments let you assign fallback values to function parameters so that callers can omit arguments that usually stay the same. Python evaluates these defaults once at definition time and stores them in the function's __defaults__ attribute. When the default is an immutable type (int, str, tuple, None, bool), this is perfectly safe. When the default is a mutable type (list, dict, set), all calls that rely on the default share the same object, leading to the infamous mutable default pitfall.
The standard fix is the None sentinel pattern: set the default to None and create a fresh mutable object inside the function body with if param is None: param = []. This ensures each call gets its own independent object. As you advance, you'll see this pattern echoed in closures, class attributes, and type hints—making it one of the most important Python fundamentals to master early.