067. Default Arguments
Sensible defaults reduce call-site burden
067. Default Arguments
Aryan’s check_threshold function needs sensible defaults — most users want the standard thresholds but should be able to override them:
def check_threshold(rss_mb, warn_mb=500, crit_mb=1000):
if rss_mb >= crit_mb:
return "CRITICAL"
if rss_mb >= warn_mb:
return "WARNING"
return "OK"
check_threshold(750) # uses defaults: warn=500, crit=1000 → "WARNING"
check_threshold(750, crit_mb=800) # tighter critical threshold → "CRITICAL"Then he writes a process logger that accumulates tags:
def log_process(name, tags=[]): # BUG: mutable default!
tags.append("monitored")
return {"name": name, "tags": tags}
r1 = log_process("Chrome") # tags = ["monitored"]
r2 = log_process("Slack") # tags = ["monitored", "monitored"] ← shared!The [] default is created once when Python parses the function. Every call that doesn’t pass tags uses the same list object. Fix: use None as the sentinel and create a fresh list inside.
💡 Fun fact: The mutable default argument bug is so notorious in Python that it appears on nearly every “Python gotchas” list ever written. It has been in the language since Python 1.x and was never “fixed” because it is technically correct behavior — default values are evaluated at function definition time, which is a deliberate design choice for performance.
⚠️ Watch out: The mutable default trap only bites when you mutate the default inside the function (e.g., tags.append(...)). If you only read it, you’re safe. Beginners often write def f(opts={}) and wonder why options accumulate mysteriously across calls.
🤔 Think about it: Why does Python evaluate default argument values once at definition time rather than fresh on each call? What would be the performance cost if it created a new [] on every function call?
Learning objectives
- Define functions with default parameter values
- Avoid the mutable default argument trap
- Use None as sentinel for optional mutable arguments
Key concepts
- default arguments
- mutable default
- None sentinel
Try it
Concept detail
Default arguments are evaluated ONCE when the function is defined, not on each call.
Safe defaults (immutable — new value each call is not needed): def f(x, n=0, name=“default”, flag=False): …
Unsafe default (mutable — shared across ALL calls!): def f(items=[]): # BUG: same list object every call items.append(1) return items f() # [1] f() # [1, 1] ← the list was kept from call 1!
The fix — use None as a sentinel: def f(items=None): if items is None: items = [] # fresh list created on each call items.append(1) return items f() # [1] f() # [1] ← independent lists now
Same pattern applies to dict defaults: def f(opts=None): if opts is None: opts = {}
Why this matters: in the RAM monitor, if a process logger has a mutable default, every logged process would accumulate tags from all previous processes.
Solution
def create_server(host, port=8080, debug=False, max_connections=100):
return {
"host": host,
"port": port,
"debug": debug,
"max_connections": max_connections,
}
def make_tag(tag, content, attrs=None):
if attrs is None:
attrs = []
attrs.append(f'class="default"')
return f"<{tag} {' '.join(attrs)}>{content}</{tag}>"Tests
def test_default_port():
config = create_server("localhost")
assert config["port"] == 8080
def test_custom_port():
config = create_server("localhost", port=3000)
assert config["port"] == 3000
def test_default_debug():
config = create_server("localhost")
assert config["debug"] == False
def test_all_defaults():
config = create_server("myhost")
assert config == {"host": "myhost", "port": 8080, "debug": False, "max_connections": 100}
def test_mutable_default_not_shared():
# Each call without attrs should get a fresh list
tag1 = make_tag("div", "Hello")
tag2 = make_tag("div", "World")
# Second call should NOT accumulate attrs from first call
assert tag2.count('class="default"') == 1
def test_make_tag_with_attrs():
result = make_tag("p", "Hi", attrs=['id="x"'])
assert 'id="x"' in result
assert 'class="default"' in result