Python Parameters vs Arguments Practice Problems & Exercises
Practice: Parameters vs Arguments
← Back to lessonEasy
Predict the output of each print call. Think about whether each function mutates the object or rebinds the local parameter.
def add_element(lst):
lst.append(99)
def replace_list(lst):
lst = [999, 888]
def replace_key(d):
d = {"y": 2}
data = [1, 2, 3]
add_element(data)
print(data)
replace_list(data)
print(data)
config = {"x": 1}
replace_key(config)
print(config)Solution
[1, 2, 3, 99]
[1, 2, 3, 99]
{'x': 1}
Step-by-step:
add_element(data)— inside the function,lstis an alias fordata. Callinglst.append(99)mutates the shared object.datais now[1, 2, 3, 99].replace_list(data)— inside the function,lst = [999, 888]rebinds the local namelstto a brand new list. The caller'sdatais completely unaffected. It remains[1, 2, 3, 99].replace_key(config)— inside the function,d = {"y": 2}rebinds the local named. The caller'sconfigstill points to the original dict{"x": 1}.
Key insight: Mutation (.append(), [key] = val) changes the object that both names share. Rebinding (=) only changes the local name — the caller never sees it.
Expected Output
[1, 2, 3, 99]\n[1, 2, 3, 99]\n{'x': 1}Hints
Hint 1: When a mutable object is passed to a function, the parameter and the argument point to the same object in memory.
Hint 2: Calling .append() mutates the object in place — all references see the change. Assigning with = rebinds the local name only.
Predict the output. All three functions attempt to "modify" their argument. Does any modification reach the caller?
def try_increment(n):
n = n + 5
def try_uppercase(s):
s = s.upper()
def try_extend_tuple(t):
t = t + (4, 5)
x = 10
try_increment(x)
print(x)
msg = "hello"
try_uppercase(msg)
print(msg)
coords = (1, 2, 3)
try_extend_tuple(coords)
print(coords)Solution
10
hello
(1, 2, 3)
Why nothing changes:
n + 5creates a new integer15and rebindsnlocally. The caller'sxstill points to10.s.upper()returns a new string"HELLO"and rebindsslocally. The caller'smsgstill points to"hello".t + (4, 5)creates a new tuple(1, 2, 3, 4, 5)and rebindstlocally. The caller'scoordsstill points to(1, 2, 3).
Key insight: Immutable types (int, str, tuple, frozenset) cannot be changed in place. Every "modification" produces a new object, and assigning it to the parameter only rebinds the local name. This is why immutable arguments behave as if Python were pass-by-value — but the mechanism is different. Python still passes a reference to the same object; it is the immutability that prevents the caller from seeing changes.
Expected Output
10\nhello\n(1, 2, 3)Hints
Hint 1: Integers, strings, and tuples are immutable. Operations on them always create new objects.
Hint 2: n + 5 creates a new int object and rebinds the local name n to it. The caller still points to the original object.
Predict the output. Use id() to trace when the parameter points to the same object as the argument and when it diverges.
original = [10, 20, 30]
original_id = id(original)
def check_identity(lst):
print(id(lst) == original_id)
lst.append(40)
print(id(lst) == original_id)
lst = [99]
print(id(lst) == original_id)
check_identity(original)Solution
True
True
False
Trace through:
id(lst) == original_idisTrue— when the function is called,lstreceives a reference to the same object asoriginal. Same object means sameid().- After
lst.append(40),id(lst) == original_idis stillTrue—.append()mutates the list in place without creating a new object. The id does not change. - After
lst = [99],id(lst) == original_idisFalse— rebinding creates a brand new list object with a different id.lstnow points to[99]whileoriginalstill points to[10, 20, 30, 40].
Key insight: id() returns the memory address of the object in CPython. Mutation does not change the id (same object, modified in place). Rebinding always changes the id (new object entirely). This is the clearest way to prove Python's pass-by-object-reference model.
Expected Output
True\nTrue\nFalseHints
Hint 1: When a mutable object is passed to a function, id(parameter) == id(argument) inside the function — they are the same object.
Hint 2: After rebinding with =, the parameter points to a different object with a different id.
Predict the output. Pay attention to how arguments are matched to parameters in each call.
def describe(name, age, city):
print(f"{name} is {age} from {city}")
describe("Alice", 30, "NYC")
describe(city="LA", name="Bob", age=25)
describe("Eve", city="Chicago", age=40)Solution
Alice is 30 from NYC
Bob is 25 from LA
Eve is 40 from Chicago
How each call works:
describe("Alice", 30, "NYC")— all positional. Matched left to right:name="Alice",age=30,city="NYC".describe(city="LA", name="Bob", age=25)— all keyword. Order does not matter; matched by name.name="Bob",age=25,city="LA".describe("Eve", city="Chicago", age=40)— mixed. The positional argument"Eve"is matched to the first parametername. The keyword argumentscityandageare matched by name. Positional arguments must come before keyword arguments in the call.
Key insight: Positional arguments and keyword arguments are two ways to pass values at the call site. The function signature defines parameters; how you pass the arguments determines whether they are positional (by position) or keyword (by name).
Expected Output
Alice is 30 from NYC\nBob is 25 from LA\nEve is 40 from ChicagoHints
Hint 1: Positional arguments are matched left to right. Keyword arguments are matched by name regardless of order.
Hint 2: You can mix positional and keyword arguments, but positional must come first.
Medium
Predict the output. Three functions touch the same config dict in different ways.
def add_debug(config):
config["debug"] = True
def reset_config(config):
config = {"host": "default", "port": 80}
def update_for_prod(config):
config.update({"host": "prod.example.com", "port": 443})
settings = {"host": "localhost", "port": 8080}
add_debug(settings)
print(settings)
reset_config(settings)
print(settings)
update_for_prod(settings)
print(settings)Solution
{'host': 'localhost', 'port': 8080, 'debug': True}
{'host': 'localhost', 'port': 8080, 'debug': True}
{'host': 'prod.example.com', 'port': 443, 'debug': True}
Analysis:
add_debug(settings)—config["debug"] = Truemutates the dict in place. The caller seesdebug: Trueadded.reset_config(settings)—config = {"host": "default", "port": 80}rebinds the local nameconfigto a completely new dict. The caller'ssettingsis unchanged. It still has all three keys from step 1.update_for_prod(settings)—config.update(...)mutates the dict in place, overwritinghostandportwhile keepingdebug. The caller sees all three changes.
Key insight: config[key] = val and config.update(...) both mutate the existing dict object. Only config = new_dict creates a new object — and that is invisible to the caller.
Expected Output
{'host': 'localhost', 'port': 8080, 'debug': True}\n{'host': 'localhost', 'port': 8080, 'debug': True}\n{'host': 'prod.example.com', 'port': 443, 'debug': True}Hints
Hint 1: dict[key] = value mutates the dict in place. The caller sees the change because the function and the caller share the same object.
Hint 2: dict.update() also mutates in place. But dict = new_dict only rebinds the local name.
Predict the output. This is one of the most famous Python gotchas.
def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1))
print(append_to(2))
print(append_to(3))
print(append_to(99, []))
print(append_to.__defaults__[0])Solution
[1]
[1, 2]
[1, 2, 3]
[99]
[1, 2, 3]
What happens:
append_to(1)— notargetargument, so Python uses the default list. Appends 1. Returns[1]. But that default list is now[1].append_to(2)— same default list (which is now[1]). Appends 2. Returns[1, 2].append_to(3)— same default list (now[1, 2]). Appends 3. Returns[1, 2, 3].append_to(99, [])— an explicit empty list is passed. Appends 99 to that new list. The default is not touched. Returns[99].append_to.__defaults__[0]— inspects the default value stored on the function object. It is[1, 2, 3]because calls 1-3 mutated it.
The fix: Use None as the default and create a new list inside the function:
def append_to(item, target=None):
if target is None:
target = []
target.append(item)
return target
Why this happens: Default values are evaluated once at function definition time (when def executes) and stored in func.__defaults__. If the default is mutable, every call that relies on it shares the same object.
Expected Output
[1]\n[1, 2]\n[1, 2, 3]\n[99]\n[1, 2, 3]Hints
Hint 1: Default argument values are evaluated ONCE when the function is defined, not each time the function is called.
Hint 2: If the default is a mutable object (like a list), all calls that use the default share the same object.
Write two functions that process data without mutating the caller's original objects. This is the defensive copy pattern — essential for writing safe, predictable functions.
def process_scores(scores):
return [s for s in scores if s >= 60]
def normalize_config(config):
result = dict(config)
defaults = {"debug": False, "verbose": False, "retries": 3}
for key, value in defaults.items():
result.setdefault(key, value)
return result
original_scores = [85, 42, 91, 55, 73, 38]
filtered = process_scores(original_scores)
print(f"Original scores: {original_scores}")
print(f"Filtered: {filtered}")
original_config = {"host": "localhost"}
normalized = normalize_config(original_config)
print(f"Original config: {original_config}")
print(f"Normalized: {normalized}")Solution
def process_scores(scores):
"""Build a new list — never modify the input."""
return [s for s in scores if s >= 60]
def normalize_config(config):
"""Copy the dict, then fill in defaults on the copy."""
result = dict(config) # shallow copy
defaults = {"debug": False, "verbose": False, "retries": 3}
for key, value in defaults.items():
result.setdefault(key, value)
return result
Why the defensive copy matters:
Without the copy in normalize_config, result.setdefault(key, value) would mutate the caller's dict directly. The caller would unexpectedly find debug, verbose, and retries keys in their original dict.
Three copy strategies:
- List comprehension — builds an entirely new list. Best for filtering or transforming.
dict(config)orconfig.copy()— shallow copy. Good for flat dicts.copy.deepcopy(config)— deep copy. Required when values are also mutable (nested dicts, lists inside dicts).
Rule of thumb: If your function should not modify the input, make a copy at the top of the function and work only on the copy. Return the copy. Never mutate the parameter.
def process_scores(scores):
"""Remove failing scores (below 60) and return the filtered list.
MUST NOT modify the caller's original list.
"""
# TODO: implement without mutating the input
pass
def normalize_config(config):
"""Add default values for missing keys and return the updated config.
MUST NOT modify the caller's original dict.
"""
# TODO: implement without mutating the input
# Defaults: debug=False, verbose=False, retries=3
passExpected Output
Original scores: [85, 42, 91, 55, 73, 38]\nFiltered: [85, 91, 73]\nOriginal config: {'host': 'localhost'}\nNormalized: {'host': 'localhost', 'debug': False, 'verbose': False, 'retries': 3}Hints
Hint 1: Use list comprehension to build a new list instead of modifying the input.
Hint 2: Use dict.copy() or {**original} to create a shallow copy of the dict before adding defaults.
Hint 3: dict.setdefault(key, value) only sets the key if it is not already present — but it mutates in place, so use it on the copy.
Predict the output. These functions demonstrate the full parameter ordering rules in Python.
def full_spec(a, b, c, *args, x=0, y=0, **kwargs):
print(a, b, c, args, f"x={x}", f"y={y}")
full_spec(1, 2, 3, 4, 5, x=10, y=20)
print("---")
def keyword_only(*, result, mode="default"):
print(f"result={result}", f"mode={mode}")
keyword_only(result=15, mode="fast")
print("---")
def positional_only(a, b, /, **kwargs):
print(a, f"b={kwargs.get('b', 'missing')}")
positional_only(3, 4, b=4)Solution
1 2 3 (4, 5) x=10 y=20
---
result=15 mode=fast
---
3 b=4
Analysis:
-
full_spec(1, 2, 3, 4, 5, x=10, y=20)—a=1, b=2, c=3fill the regular parameters.4, 5are captured by*argsas(4, 5).x=10, y=20are keyword-only parameters. -
keyword_only(result=15, mode="fast")— everything after*is keyword-only. You cannot callkeyword_only(15, "fast")— that would raiseTypeError: keyword_only() takes 0 positional arguments. -
positional_only(3, 4, b=4)—aandbare before/, so they can only be passed positionally. The positional4fills parameterb. The keywordb=4goes into**kwargsbecause the parameterbis positional-only and does not "claim" keyword arguments. Inside the function,kwargsis{"b": 4}.
Parameter ordering rule:
def f(pos_only, /, regular, *args, kw_only, **kwargs)
^^^^^^^ ^^^^^^^ ^^^^ ^^^^^^^ ^^^^^^
before / normal extra after * extra kw
Expected Output
1 2 3 (4, 5) x=10 y=20\n---\nresult=15 mode=fast\n---\n3 b=4Hints
Hint 1: Python parameter order: positional-only (before /) → regular → *args → keyword-only (after *) → **kwargs.
Hint 2: Parameters after * can only be passed as keyword arguments. Parameters before / can only be passed as positional arguments.
Hard
Predict the output. This problem exposes the critical difference between shallow copy and deep copy when dealing with nested mutable structures.
import copy
def modify_shallow(config):
config = config.copy()
config["users"][0]["role"] = "admin"
return config
def modify_deep(config):
config = copy.deepcopy(config)
config["users"][0]["role"] = "viewer"
return config
original = {
"users": [
{"name": "Alice", "role": "viewer"},
{"name": "Bob", "role": "user"},
],
"version": 1,
}
result1 = modify_shallow(original)
print(original)
print(original["users"][0] is result1["users"][0])
original["users"][0]["role"] = "viewer"
result2 = modify_deep(original)
print(original)
print(original["users"][0] is result2["users"][0])
print(result2)
print(original["users"][0]["role"] == "viewer")Solution
{'users': [{'name': 'Alice', 'role': 'admin'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}
True
{'users': [{'name': 'Alice', 'role': 'viewer'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}
False
{'users': [{'name': 'Alice', 'role': 'viewer'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}
True
Step-by-step:
-
modify_shallow—config.copy()creates a new outer dict, butconfig["users"]still points to the same list, and that list contains the same inner dicts. Changingconfig["users"][0]["role"]mutates the original inner dict. Sooriginalshowsrole: admin. Theischeck isTrue— same inner dict object. -
We reset
original["users"][0]["role"]back to"viewer". -
modify_deep—copy.deepcopy(config)recursively copies everything: the outer dict, theuserslist, and each inner dict. Changingconfig["users"][0]["role"]only affects the deep copy.originalis untouched. Theischeck isFalse— different inner dict objects. -
result2showsrole: viewer(the deep copy was modified independently).originalstill hasrole: viewer(unchanged by the deep copy function). The final equality check isTrue.
Memory picture:
Shallow copy:
original["users"] ──► [ptr0, ptr1]
copy["users"] ──► [ptr0, ptr1] (same inner dicts!)
│
▼
{"name":"Alice","role":"admin"} (shared!)
Deep copy:
original["users"] ──► [ptr0, ptr1] ──► {"name":"Alice","role":"viewer"}
copy["users"] ──► [ptr2, ptr3] ──► {"name":"Alice","role":"viewer"} (independent!)
Rule: Use copy.deepcopy() when your data has nested mutable structures and you need full isolation.
Expected Output
{'users': [{'name': 'Alice', 'role': 'admin'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}\nTrue\n{'users': [{'name': 'Alice', 'role': 'admin'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}\nFalse\n{'users': [{'name': 'Alice', 'role': 'viewer'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}\nTrueHints
Hint 1: A shallow copy (dict.copy()) creates a new outer dict, but the inner values still reference the same objects.
Hint 2: If you mutate a nested object (like a dict inside a list inside the copied dict), the original sees the change — the shallow copy does not protect nested structures.
Hint 3: copy.deepcopy() recursively copies all nested objects, fully isolating the copy from the original.
Implement two accumulator factories that avoid the mutable default argument trap. Each factory returns a function that appends to its own independent collection. Optionally accept an initial collection to start from (without mutating the original).
def make_accumulator(initial=None):
if initial is None:
items = []
else:
items = list(initial)
def accumulate(item):
items.append(item)
return items
return accumulate
def make_dict_accumulator(initial=None):
if initial is None:
store = {}
else:
store = dict(initial)
def accumulate(key, value):
store[key] = value
return store
return accumulate
# Test list accumulator — independent instances
acc1 = make_accumulator()
print(acc1(1))
print(acc1(2))
print(acc1(3))
acc2 = make_accumulator()
print(acc2("a"))
print(acc2("b"))
# Test with initial value
acc3 = make_accumulator([10, 20])
print(acc3(1))
print(acc3(2))
# Test dict accumulator
dacc1 = make_dict_accumulator()
print(dacc1("x", 1))
print(dacc1("y", 2))
dacc2 = make_dict_accumulator()
print(dacc2("a", 10))Solution
def make_accumulator(initial=None):
if initial is None:
items = []
else:
items = list(initial) # Copy to avoid mutating caller's list
def accumulate(item):
items.append(item)
return items
return accumulate
def make_dict_accumulator(initial=None):
if initial is None:
store = {}
else:
store = dict(initial) # Copy to avoid mutating caller's dict
def accumulate(key, value):
store[key] = value
return store
return accumulate
Why this works correctly:
-
No mutable default: The default is
None(immutable). A new list/dict is created inside the function body on every call to the factory. -
Closure isolation: Each call to
make_accumulator()creates a new local variableitems. The inneraccumulatefunction closes over this variable. Different accumulators close over different lists — they are fully independent. -
Defensive copy of initial:
list(initial)creates a shallow copy. Without this, passing a list asinitialwould create an alias — the accumulator would mutate the caller's original list.
The anti-pattern this avoids:
# BAD — all callers share the same default list
def make_accumulator_bad(items=[]):
def accumulate(item):
items.append(item)
return items
return accumulate
With the bad version, every accumulator returned by make_accumulator_bad() would share the same list, because the default [] is evaluated once at definition time.
def make_accumulator(initial=None):
"""Return a function that accumulates items into a list.
Each call to the returned function appends an item and returns
the current list. Different accumulators must be independent.
Must not use the mutable default argument anti-pattern.
"""
# TODO: implement
pass
def make_dict_accumulator(initial=None):
"""Return a function that accumulates key-value pairs into a dict.
Each call takes (key, value) and returns the current dict.
Different accumulators must be independent.
"""
# TODO: implement
passExpected Output
[1]\n[1, 2]\n[1, 2, 3]\n['a']\n['a', 'b']\n[10, 20, 1]\n[10, 20, 1, 2]\n{'x': 1}\n{'x': 1, 'y': 2}\n{'a': 10}Hints
Hint 1: Use None as the default and create a new list/dict inside the function body — the standard pattern for avoiding mutable defaults.
Hint 2: The returned inner function closes over the local variable, creating an independent accumulator per call.
Hint 3: When initial is provided, make a copy of it so the caller s original is not mutated.
Build a decorator that audits whether a function mutated any of its mutable arguments. This is a powerful debugging tool for catching unintended side effects.
import functools
import copy
def audit_mutation(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
mutable_types = (list, dict, set)
# Snapshot positional args
pos_snapshots = {}
for i, arg in enumerate(args):
if isinstance(arg, mutable_types):
pos_snapshots[i] = copy.deepcopy(arg)
# Snapshot keyword args
kw_snapshots = {}
for key, val in kwargs.items():
if isinstance(val, mutable_types):
kw_snapshots[key] = copy.deepcopy(val)
result = func(*args, **kwargs)
# Check positional args for mutation
mutated = False
for i, snapshot in pos_snapshots.items():
if args[i] != snapshot:
print(f"[WARNING] {func.__name__} mutated argument {i}: {type(args[i]).__name__}")
mutated = True
# Check keyword args for mutation
for key, snapshot in kw_snapshots.items():
if kwargs[key] != snapshot:
print(f"[WARNING] {func.__name__} mutated argument '{key}': {type(kwargs[key]).__name__}")
mutated = True
if not mutated:
print(f"{func.__name__}: no mutation warnings")
return result
return wrapper
@audit_mutation
def safe_process(data):
return [x * 2 for x in data]
@audit_mutation
def dangerous_process(data):
data.append(99)
return data
@audit_mutation
def sneaky_update(config):
config["b"] = 2
original = [1, 2, 3]
safe_process(original)
print(f"Original after safe: {original}")
dangerous_process(original)
print(f"Original after dangerous: {original}")
conf = {"a": 1}
sneaky_update(config=conf)
print(f"Config after sneaky: {conf}")Solution
import functools
import copy
def audit_mutation(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
mutable_types = (list, dict, set)
# Snapshot all mutable positional arguments
pos_snapshots = {}
for i, arg in enumerate(args):
if isinstance(arg, mutable_types):
pos_snapshots[i] = copy.deepcopy(arg)
# Snapshot all mutable keyword arguments
kw_snapshots = {}
for key, val in kwargs.items():
if isinstance(val, mutable_types):
kw_snapshots[key] = copy.deepcopy(val)
# Call the original function
result = func(*args, **kwargs)
# Compare each mutable argument to its snapshot
mutated = False
for i, snapshot in pos_snapshots.items():
if args[i] != snapshot:
print(f"[WARNING] {func.__name__} mutated argument {i}: {type(args[i]).__name__}")
mutated = True
for key, snapshot in kw_snapshots.items():
if kwargs[key] != snapshot:
print(f"[WARNING] {func.__name__} mutated argument '{key}': {type(kwargs[key]).__name__}")
mutated = True
if not mutated:
print(f"{func.__name__}: no mutation warnings")
return result
return wrapper
How the audit works:
- Before the call: For every argument that is a mutable type (list, dict, set), take a
deepcopysnapshot. This captures the state before the function runs. - After the call: Compare each mutable argument to its snapshot using
==. If they differ, the function mutated that argument. - Report: Print a warning identifying which argument was mutated, by position or keyword name.
Why deepcopy is necessary for snapshots: A shallow copy would fail for nested structures. If the function mutates a nested list inside a dict, a shallow copy of the dict would also reflect the change (since the inner list is shared). deepcopy guarantees a fully independent snapshot.
Production considerations:
deepcopyis expensive for large objects. In production, you would use this decorator only in debug/test mode.- This does not catch mutation of custom objects unless they implement
__eq__correctly. - A more advanced version could use
id()checks on nested objects to detect structural changes without full value comparison.
import functools
import copy
def audit_mutation(func):
"""Decorator that detects whether a function mutated any of its
mutable arguments (list, dict, set).
After the function runs, compare each mutable argument to a
snapshot taken before the call. Print a warning for each
mutated argument.
Return the function's original return value.
"""
# TODO: implement the decorator
passExpected Output
safe_process: no mutation warnings\nOriginal after safe: [1, 2, 3]\n[WARNING] dangerous_process mutated argument 0: list\nOriginal after dangerous: [1, 2, 3, 99]\n[WARNING] sneaky_update mutated argument 'config': dict\nConfig after sneaky: {'a': 1, 'b': 2}Hints
Hint 1: Use copy.deepcopy() to snapshot each mutable argument before the call. After the call, compare with == to detect changes.
Hint 2: Check both positional args and keyword kwargs for mutable types (list, dict, set).
Hint 3: Use functools.wraps to preserve the original function metadata.
