Default vs Keyword Arguments & The Mutable Default Trap
Default vs Keyword Arguments & The Mutable Default Trap
In Python, function parameters can be supplied with default values to make arguments optional. While intuitive at first glance, parameter evaluation in Python operates under a crucial architectural mechanism that catches even experienced engineers off-guard: Default arguments are evaluated exactly once at definition time (when the function is first loaded into memory), NOT every time the function is called.
This mechanism leads to one of Python's most notorious bugs: the Mutable Default Argument Trap. In this lesson, you will master function parameter mechanics, inspect __defaults__ in memory, implement the None sentinel pattern, and utilize positional-only (/) and keyword-only (*) markers.
Real-World Analogy: The Shared Office Chai Thermos
Imagine a shared community chai thermos in a busy Indian office pantry:
+-------------------------------------------------------------------------+
| THE SHARED OFFICE CHAI THERMOS ANALOGY |
+-------------------------------------------------------------------------+
| |
| The Mutable Trap: def pour_chai(cup, tray=[]): |
| ──> Python creates ONE single physical tray in RAM at compile time! |
| |
| Employee 1 (Aman): Calls pour_chai("Masala Chai") |
| ──> Puts cup on the shared tray: ['Masala Chai'] |
| |
| Employee 2 (Priya): Calls pour_chai("Ginger Tea") |
| ──> Expects a fresh tray, but receives Aman's leftover tray! |
| ──> Tray now holds: ['Masala Chai', 'Ginger Tea'] (Data Leakage!) |
| |
| The Sentinel Fix: def pour_chai(cup, tray=None): |
| ──> if tray is None: tray = [] |
| ──> Every employee gets their own fresh, private, clean tray! |
| |
+-------------------------------------------------------------------------+When you write tray=[] in the def header, Python binds a single list in heap memory when the file is imported. Every call that omits the parameter mutates that exact same list object.
Under the Hood: The __defaults__ Tuple
To understand why this occurs, inspect the function object's internal attribute __defaults__:
Because Python functions are first-class objects created when the def statement executes, the default value expression is evaluated once and attached to func.__defaults__.
The Gold-Standard Sentinel Pattern
To safely supply a fresh mutable container (list, dictionary, set) on every call, use None as the default argument:
+------------------------------------+------------------------------------+ | Anti-Pattern (Dangerous Mutation) | Gold-Standard (Sentinel Pattern) | +------------------------------------+------------------------------------+ | def log_event(msg, events=[]): | def log_event(msg, events=None): | | events.append(msg) | if events is None: | | return events | events = [] # Fresh! | | | events.append(msg) | | | return events | +------------------------------------+------------------------------------+
Comprehensive Code Examples
1. The Mutable Default Trap Demonstrated and Fixed
Expected Output:
2. Positional-Only (/) vs Keyword-Only (*) Parameters
Introduced in Python 3.8 (PEP 570), the forward slash / indicates that parameters before it are positional-only, while the asterisk * indicates that parameters after it are keyword-only:
Visual Architecture & Process Flow
How data and code flow step-by-step
Expected Output:
3. Dynamic Default Evaluation (The Datetime Trap)
Another classic pitfall is calling a function like datetime.now() in the parameter definition:
Expected Output:
Best Practices & Comparison: Do's and Don'ts
| Practice | Dangerous Anti-Pattern | Recommended Gold Standard |
|---|---|---|
| Mutable Defaults | def fn(data=[]): | def fn(data=None): if data is None: data = [] |
| Dict Defaults | def config(options={}): | def config(options=None): if options is None: options = {} |
| Timestamps | def stamp(time=datetime.now()): | def stamp(time=None): if time is None: time = datetime.now() |
| API Parameter Locks | Exposing internal variable names blindly | Use / for positional-only to safely rename parameters later |
| Flags & Booleans | def process(x, False, True): (Cryptic!) | def process(x, *, dry_run=False, verbose=True): (Clear!) |
Quick Revision Summary Cheat Sheet
- Definition-Time Evaluation: Default argument expressions execute once when the function is defined, not per call.
- The Mutable Trap: Using
[],{}, orset()as defaults leads to state sharing across independent invocations. - The Sentinel Idiom: Set parameter to
None, then instantiateval = []orval = {}inside the function body. - Positional-Only (
/): Parameters to the left of/cannot be called vianame=val. - *Keyword-Only (`
):** Parameters to the right of*must be specified explicitly vianame=val`.
Multiple Choice Questions
1. When is a default argument expression like def func(x=[]): evaluated in Python?
A. Every time the function is called B. Exactly once, when the def statement is first executed by the interpreter C. Only when an error occurs D. When the program exits Answer: B Explanation: Python evaluates default parameter expressions once at function definition time, storing them in the function's __defaults__ tuple.
2. What is the output of the following code snippet?
A. 1 1 1 B. 1 2 3 C. 3 3 3 D. TypeError Answer: B Explanation: Because box=[] is created once, each invocation appends to the same list. On call 1, box has 1 item; on call 2, it has 2 items; on call 3, it has 3 items. The output is 1 2 3.
3. What is the recommended idiom to avoid the mutable default argument trap?
A. Use a tuple as default: box=() B. Set the default to None and initialize the mutable object inside the function body if the argument is None C. Pass an empty string D. Delete the function after each use Answer: B Explanation: The None sentinel pattern (def func(param=None): if param is None: param = []) ensures a brand-new list is allocated dynamically in memory on every call where no argument is passed.
4. Given def calculate(a, b, /, c, *, d):, how can argument 'a' be passed?
A. Only as a keyword argument: calculate(a=1, ...) B. Only positionally: calculate(1, ...) C. Either positionally or as keyword D. It cannot be passed Answer: B Explanation: In Python parameter syntax, all parameters preceding the slash / are positional-only. Passing a as a keyword argument raises a TypeError.
5. Why should datetime.now() NOT be written directly as a default argument: def create_record(timestamp=datetime.now())?
A. Because datetime objects cannot be default arguments B. Because it freezes the timestamp to the exact millisecond when the script was launched, rather than recording the actual time of each record creation C. Because it consumes 100% CPU D. Because datetime.now() is an asynchronous coroutine Answer: B Explanation: Since default expressions evaluate at import/definition time, timestamp will hold the static timestamp of when the function was compiled, failing to capture the time of future function calls.
Practice Challenge
Scenario: Safe Multi-Tenant Banking Transaction Logger
A banking system in Mumbai records customer transactions. A junior developer wrote the following flawed function:
Because of the mutable default argument trap, transactions from different bank customers are leaking into each other's audit logs!
Your task:
- 1Refactor
record_transactionusing the Sentinel Pattern (audit_log=None). - 2Add positional-only constraints so that
account_idandamountmust be passed positionally. - 3Add a keyword-only constraint so that
txn_typemust be passed as a keyword argument (*, txn_type="Credit"). - 4Demonstrate that two independent transactions for different customers maintain completely separate, isolated audit logs.
Starter Code
Complete Solution
Expected Output
Closures
Continue learning with hands-on practice, examples, and exercises in the upcoming topic.
Related Lessons
| Previous Lesson | Next Lesson |
|---|---|
| Arguments Recap (*args, **kwargs) | Closures |
Practice Quiz
Test your understanding of this lesson with 5 questions. Each question has one correct answer.