PYTHON • FUNCTIONS AND PROGRAM STRUCTURE

Default Arguments & Mutable Defaults — Use default arguments and avoid mutable default pitfalls (conceptual + example awareness)

Learn how default parameters simplify function calls and why mutable defaults can cause sneaky bugs.

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
Guido van Rossum releases the first public version of Python, which already supports functions with positional arguments.
1994
Python 1.0 — Default Arguments Arrive
Default parameter values become a core language feature, allowing cleaner APIs and fewer boilerplate checks.
2001
PEP 8 & Community Awareness
As the community grows, the mutable default pitfall becomes one of the most frequently asked questions in Python forums, and best-practice guides begin warning about it.
2008
Python 3.0 Released
Python 3 keeps the same default-argument semantics. The mutable default behavior is considered intentional, not a bug—developers are expected to understand and work around it.

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?

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).

1

Default Argument

A fallback value specified in the function signature. Example: def greet(name="World"). If no name is passed, the function uses "World".
2

Definition-Time Evaluation

Python evaluates the default value expression once—when the def statement runs, not each time the function is called.
3

Mutable vs. Immutable

Immutable defaults (int, str, tuple, None) are safe because they can't change. Mutable defaults (list, dict, set) are risky because all calls share the same object.
4

The None Sentinel Pattern

The standard fix: set the default to None, then create a fresh mutable object inside the function body if the argument is None.
✦ KEY TAKEAWAY
Think of a default argument like a sticky note glued to the function when it's first created. If that sticky note points to a notebook (a mutable object like a list), every caller who uses the default is writing in the same notebook. If it points to a number or a string, nobody can scribble on it because those are immutable. To give each caller a fresh notebook, use None as the sticky note and hand out a new notebook inside the function.

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.

The function's __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.

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

  1. Step 1 — Encounter def: Python reads the function header and evaluates each default expression left-to-right.
  2. Step 2 — Store defaults: The evaluated objects are packed into func.__defaults__.
  3. Step 3 — Call without argument: Python looks up the corresponding default from __defaults__ and binds it to the parameter name.
  4. 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?
Some students ask why Python doesn't just re-evaluate defaults on every call. The reason is that evaluating an expression every time could be expensive or have side effects. Python's design gives you the control: use None as a sentinel when you want a fresh mutable object each time.

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.

Left column: immutable types that are safe as defaults. Right column: mutable types that can cause the shared-state bug. Each unsafe type shows the None sentinel fix underneath.
Quick-reference table: which types are safe as defaults?
TypeMutable?Safe as Default?Recommended Default
int, float, boolNo✓ YesUse directly, e.g. 0, 1.0
strNo✓ YesUse directly, e.g. "hello"
tuple, frozensetNo✓ YesUse directly
listYes✗ NoUse None, create inside
dictYes✗ NoUse None, create inside
setYes✗ NoUse None, create inside

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 defaultWe write: def log(msg, messages=[]): followed by messages.append(msg) and return messages. The empty list [] is evaluated once when Python processes the def statement.
2
Step 2 — First callWe call log("Error A"). Since we didn't pass messages, 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 bugWe call log("Error B"). Again, messages binds 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 fixRewrite the function: def log(msg, messages=None): then inside the body: if messages is None: messages = []. Now each call that doesn't pass messages gets a brand-new empty list.
5
Step 5 — Verify the fixCalling log("Error A") returns ["Error A"]. Calling log("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. ✓

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.

Default arguments: strengths vs. pitfalls
StrengthsPitfalls
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.
⚡ RULE OF THUMB
Treat mutable defaults like sharing your phone charger: if one person changes the cord length, everyone after them gets the modified version. Use 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.

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.

How this lesson connects to advanced Python topics
This LessonAdvanced Concept
Default arguments evaluated at definition timeClosures — inner functions capture variables from their enclosing scope at definition time, a similar "snapshot" idea.
Using None as a sentinelSentinel objects — libraries sometimes create custom sentinel objects (e.g., _MISSING = object()) for more nuanced defaults.
Shared mutable state across callsClass attributes vs. instance attributes — a class-level list is shared by all instances, the same pattern as a mutable default.
Immutable defaults are safeFunctional programming — favoring immutable data eliminates a whole class of bugs, a principle you'll see in languages like Haskell and Rust.

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.

Practice Problems

PROBLEM 1 — CONCEPTUAL
In your own words, explain why Python evaluates default argument values at definition time rather than at call time. What advantage does this design choice offer?
PROBLEM 2 — BASIC CALCULATION
Consider this function: 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.
PROBLEM 3 — INTERMEDIATE
Rewrite the following function so it no longer has the mutable default bug: 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.
PROBLEM 4 — APPLIED
You're building a web application where a function generates a user profile dictionary. The function signature is: 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.
PROBLEM 5 — CRITICAL THINKING
Some experienced Python developers intentionally use mutable defaults as a feature, not a bug. For example: 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?

Lesson 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.

Varsity Tutors • Python • Default Arguments & Mutable Defaults