Exercises — Default parameters, keyword arguments
Before we start, a one-line reminder of the two words we lean on constantly:
Level 1 — Recognition
Here you only need to spot whether something is legal and read off an output. No design yet.
L1.1 — Legal or not?
For each definition line, say whether Python accepts it. If not, name the broken rule.
def a(x, y=1): ... # (i)
def b(x=1, y): ... # (ii)
def c(x, y=1, z=2): ... # (iii)
def d(x=1, y=2): ... # (iv)Recall Solution L1.1
Rule 1 says: non-default parameters must come before default ones (defaults drift right).
- (i) ✅ legal —
x(no default) beforey=1(default). - (ii) ❌
SyntaxError—x=1(default) sits beforey(no default). Order is backwards. - (iii) ✅ legal — one non-default, then two defaults, all in the right order.
- (iv) ✅ legal — all parameters have defaults, so there is no "non-default after default" problem.
L1.2 — Read the output
def greet(name, greeting="Hello"):
return greeting + ", " + name
print(greet("Ravi"))
print(greet("Ravi", "Namaste"))
print(greet("Ravi", greeting="Hi"))Write the three printed lines.
Recall Solution L1.2
greet("Ravi")→greetingis omitted, so its default"Hello"is used →Hello, Ravi.greet("Ravi", "Namaste")→ the second value fillsgreetingby position →Namaste, Ravi.greet("Ravi", greeting="Hi")→ the keyword names the slot directly →Hi, Ravi.
Level 2 — Application
Now you apply the rules to produce or fix calls.
L2.1 — Skip the middle
def power(base, exp=2, mod=None):
r = base ** exp
return r if mod is None else r % modWrite a single call that computes 5 ** 2 and then takes it mod 7, without writing the number 2 anywhere. Give the numeric result.
Recall Solution L2.1
You want exp to keep its default 2, but you must set mod. Positionally you'd have to write power(5, 2, 7) — which forces you to type 2. The keyword lets you jump over exp:
power(5, mod=7)Computation: exp stays 2, so r = 5 ** 2 = 25; then 25 % 7 = 4. Result = 4.
This is the whole reason keyword arguments exist: reach a later knob while leaving earlier ones untouched.
L2.2 — Fix the broken call
This raises an error:
def box(width, height, x=0, y=0): ...
box(x=1, 5)Explain the error and rewrite the call so it sets width=5 and x=1.
Recall Solution L2.2
The error: SyntaxError: positional argument follows keyword argument. Once you write x=1 (a keyword), the bare 5 after it has no known slot — Rule 2 says all positional arguments must come before any keyword argument.
The fix: put positionals first.
box(5, x=1) # width=5 (positional), x=1 (keyword)Here width gets 5, height/y keep... wait — height has no default, so this would still fail with a missing-argument error. Correct, complete call:
box(5, 3, x=1) # width=5, height=3, x=1, y keeps default 0L2.3 — Multiple-values error
Predict the exact error:
def f(name, x=0): ...
f("Asha", name="Asha")Recall Solution L2.3
The positional "Asha" already fills name. Then name="Asha" tries to fill the same slot again.
Result: TypeError: f() got multiple values for argument 'name'.
The values being equal does not save you — Python complains about the slot being assigned twice, not about the values disagreeing.
Level 3 — Analysis
Now you trace hidden behaviour and explain why it happens.
L3.1 — The mutable-default trap
def add(item, bag=[]):
bag.append(item)
return bag
print(add(1))
print(add(2))
print(add(3))Predict all three lines and explain the mechanism.
Recall Solution L3.1
Output:
[1]
[1, 2]
[1, 2, 3]
Mechanism: the default value [] is created once, when the def line runs — not on each call. So every call that omits bag reuses the same list object, and .append keeps growing it. The picture: one shared bucket, not a fresh bucket per call. See the figure.

L3.2 — Fix it and re-predict
Rewrite add using the None sentinel, then predict the same three calls.
Recall Solution L3.2
def add(item, bag=None):
if bag is None:
bag = [] # fresh list on THIS call
bag.append(item)
return bagNow add(1) → [1], add(2) → [2], add(3) → [3]. Each call that omits bag builds its own new list inside the body, so there is nothing shared. See how the buckets separate in the figure:

L3.3 — Why is None, not == []?
In L3.2 we tested if bag is None. Why not if bag == []?
Recall Solution L3.3
is None asks: "was the caller silent?" — did nothing get passed?
== [] asks: "is whatever they passed empty?" — a very different question.
If a caller legitimately passes their own empty list they intend you to fill, == [] would wrongly throw it away and substitute a new one. is None respects the caller's list. So: is None distinguishes absence from an empty-but-real value.
Level 4 — Synthesis
Now you design functions that satisfy several constraints at once.
L4.1 — Design a logger
Write log(msg, level="INFO", timestamp=None) that returns a string like "[INFO] hello". If timestamp is given (a string), prepend it: "2024 [INFO] hello". Make sure the common case log("hello") stays tiny.
Recall Solution L4.1
def log(msg, level="INFO", timestamp=None):
head = f"[{level}] {msg}"
return head if timestamp is None else f"{timestamp} {head}"log("hello")→"[INFO] hello"(both defaults used — the tiny 80% case).log("bad", level="ERROR")→"[ERROR] bad"(keyword skips nothing here but names the knob clearly).log("hi", timestamp="2024")→"2024 [INFO] hi"(jump overlevelusing a keyword — same trick as L2.1). Notetimestamp=Noneis the safe sentinel default;level="INFO"is a safe immutable string default.
L4.2 — Design without the trap
Write record(name, entries=None) that appends name to a list and returns it, so that repeated calls do not share state. Then show record("a") and record("b") give independent lists.
Recall Solution L4.2
def record(name, entries=None):
entries = [] if entries is None else entries
entries.append(name)
return entriesrecord("a")→["a"]record("b")→["b"](independent — no shared bucket)record("c", ["x"])→["x", "c"](a caller's own list is respected and extended). This is the DRY, safe pattern: one line of guard, then normal logic. It respects the DRY principle - Don't Repeat Yourself by keeping the fresh-list logic in exactly one place.
Level 5 — Mastery
Full-integration problems. Combine ordering rules, keywords, sentinels, and scope reasoning.
L5.1 — Trace everything
def build(a, b=10, c=None):
if c is None:
c = []
c.append(a + b)
return c
x = build(1)
y = build(2, 20)
z = build(3, c=x)
print(x)
print(y)
print(z)Predict x, y, z, and explain why z looks the way it does.
Recall Solution L5.1
Step by step:
build(1):b=10(default),comitted → new list[], append1+10=11→x = [11].build(2, 20):b=20positionally,comitted → new list, append2+20=22→y = [22].build(3, c=x):b=10(default),cis the same objectx. Append3+10=13into x → the list becomes[11, 13]. Sozisx. Output:
[11, 13] # x, mutated by the last call
[22] # y, independent
[11, 13] # z is the same object as x
Key insight: no default was misused (we used the None sentinel correctly). The sharing here is deliberate — the caller passed x in on purpose, so z is x. This connects to Mutable vs immutable objects: passing a mutable object means the callee can change it.
L5.2 — Repair a specification
A teammate wants: render(items, sep=", ", prefix=[]) that joins items after storing them in prefix. It "sometimes remembers old items". Diagnose the bug, then rewrite so it never leaks, while still letting a caller supply their own prefix list.
Recall Solution L5.2
Diagnosis: prefix=[] is a mutable default created once at def time. If render does prefix.extend(items) and a caller omits prefix, every such call mutates the same shared list — that is the "remembers old items" symptom.
Rewrite:
def render(items, sep=", ", prefix=None):
prefix = [] if prefix is None else prefix
out = list(prefix) # copy so we never mutate the caller's list either
out.extend(items)
return sep.join(out)render(["a", "b"])→"a, b"(fresh, no memory).render(["c"], sep=" | ")→"c"(single item, keywordsepused).render(["z"], prefix=["p"])→"p, z"(caller's prefix respected, and copied so their list stays untouched). Usinglist(prefix)is the extra-safe touch: even when a real list is passed, we don't surprise the caller by mutating their object.
L5.3 — Explain the design choice
Why does Python evaluate defaults once (at definition) instead of every call? Give the trade-off in one line.
Recall Solution L5.3
Evaluating once is cheaper and predictable: the default expression runs a single time, so a default like heavy_computation() isn't re-run on every call. The trade-off is the mutable-default trap — a shared object survives between calls. Python chose speed/predictability and asks you to use the None sentinel for the mutable case. So: one evaluation buys performance; you pay with the discipline of None defaults.
Connections
- Default parameters, keyword arguments — the parent note these exercises drill.
- Functions - def and return
- Variable scope - local vs global
- *args and **kwargs (variadic functions)
- Mutable vs immutable objects
- DRY principle - Don't Repeat Yourself