Python Part I · Module 11 · Functions
Passing Lists and Dictionaries to Functions
Passing a number to a function is uneventful: the function gets the value and nothing the caller holds can be affected. Passing a list is different in a way that catches essentially everyone once, because the function does not receive a copy — it receives the same list. That means a function you called for one reason can quietly change data you are still using, and it also means the obvious mental fix does not work. The chapter settles both halves with one pair of programs that differ by a single line, and the rule that comes out of it is one you have already met twice in this course under different names.
Intermediate16 min readPythonPart IFundamentalsFunctionsMutability
Part I · Module 11 · Chapter 11.7 · Passing Lists and Dictionaries to Functions
1. A Number Is Uneventful
def bump(n):
n = n + 1
return n
count = 10
print(bump(count))
print(count)11
10The function produced 11; count is still 10. Nothing the function did to its parameter reached the caller, which is what most people expect of a function call.
2. A List Is Not
def add_test(suite):
suite.append("burst")
tests = ["smoke"]
add_test(tests)
print(tests)['smoke', 'burst']add_test returns nothing, and the caller's list changed anyway.
That is the whole surprise of this chapter, and it is not a special rule about functions. The function received the same list — not a copy of it, not a view of it, the same object the name tests refers to. suite.append(...) is Chapter 5.3's method, operating on that object, and tests is still pointing at it afterwards.
def show(items):
print("same object:", items is OUTSIDE)
OUTSIDE = ["smoke"]
show(OUTSIDE)same object: TrueChapter 3.6's is confirms it directly: the parameter and the caller's name refer to one object.
3. But Rebinding Does Not Reach the Caller
Now the same shape with one line changed — assigning a whole new list instead of appending to the existing one:
def replace(suite):
suite = ["entirely", "new"]
print("inside :", suite)
tests = ["smoke"]
replace(tests)
print("outside:", tests)inside : ['entirely', 'new']
outside: ['smoke']Inside the function the name suite now refers to a new list. Outside, tests is untouched.
Put the two side by side and the model falls out:
| Inside the function | Reaches the caller? | Why |
|---|---|---|
suite.append("burst") | yes | changes the object both names point at |
suite = ["new"] | no | points the local name at a different object |
One object, two names. A method that modifies the object is visible through both names. An assignment that re-points one name leaves the other where it was.
4. You Have Met This Twice Already
This is not a new rule. It is the third appearance of one idea.
Chapter 2.3 established it for plain assignment: errors = total copies a reference at that instant, and later changes to total do not follow.
Chapter 10.1, Section 5 established it for loop variables: test = test.upper() inside a loop leaves the list completely unchanged, because assigning to the loop variable re-points a name rather than writing into the collection.
This chapter is the same fact for parameters. A parameter is a name, bound to the argument's object at the moment of the call — exactly like a loop variable, exactly like any other assignment.
5. Dictionaries Behave the Same Way
Nothing about this is specific to lists. Any mutable object — a list, a dictionary, a set — behaves identically:
def record(results, name, status):
results[name] = status
status = {"smoke": "PASS"}
record(status, "reset", "FAIL")
print(status){'smoke': 'PASS', 'reset': 'FAIL'}And the immutable types cannot be affected at all, because there is no operation that changes them. A tuple argument is safe by construction:
def add(record):
record.append("x")
row = ("smoke", 4210)
add(row)AttributeError: 'tuple' object has no attribute 'append'Chapter 6.4's immutability, doing a job it was not obviously designed for: a tuple parameter is a guarantee that the function cannot alter what it was given.
| Argument type | Can the function change the caller's value? |
|---|---|
int, float, str, bool | no — immutable |
tuple | no — immutable |
list, dict, set | yes |
6. Why the Mutable Default Grew
Chapter 11.5 Section 6 showed a default list accumulating across calls and deferred the explanation to this chapter. Here it is:
def add(item, bucket=[]):
bucket.append(item)
return bucket
print(add("a"))
print(add("b"))['a']
['a', 'b']The default [] was evaluated once, when the def ran (Chapter 11.5's rule), producing one list object stored with the function. Every call that omits bucket binds the parameter to that same object — and .append() changes the object.
It is Section 2's behaviour exactly, with the caller's list replaced by the function's own hidden one. The two facts together are the whole explanation, and neither needs memorising once the model from Section 4 is in place.
7. Three Defences
Copy it on the way in. The function works on its own list and the caller's is untouched:
def add_test(suite):
suite = list(suite)
suite.append("burst")
return suite
tests = ["smoke"]
result = add_test(tests)
print("caller :", tests)
print("result :", result)caller : ['smoke']
result : ['smoke', 'burst']list(suite) is Chapter 5.8's copy. Note that the function now must return something, or its work is invisible.
Build and return a new collection. The accumulator pattern from Chapter 10.10, which never touches the input at all:
def without(suite, unwanted):
kept = []
for test in suite:
if test != unwanted:
kept.append(test)
return kept
tests = ["smoke", "skip_me", "reset"]
print(without(tests, "skip_me"))
print(tests)['smoke', 'reset']
['smoke', 'skip_me', 'reset']Take an immutable type. If the function has no business changing its input, a tuple parameter makes that impossible — Section 5's AttributeError.
8. When Mutating Is the Right Choice
Not every mutation is a bug. It is right when changing the collection is the function's stated job:
def drain(queue):
processed = []
while queue:
processed.append(queue.pop())
return processed
jobs = ["j1", "j2", "j3"]
done = drain(jobs)
print("done :", done)
print("queue:", jobs)done : ['j3', 'j2', 'j1']
queue: []drain emptying its argument is not a surprise — it is what the name promises. The problem in Section 2 was never mutation itself; it was mutation that the function's name and signature gave no warning of.
Two rules follow:
- Say so in the name.
add_test,drain,sort_in_placeannounce a change.count_errors,classify,summarisemust not make one. - Do not both mutate and return the modified object. It invites
new = sort_in_place(old)and leaves the caller believingoldis unchanged. Mutate and returnNone, or copy and return the new value — not both.
Python's own library follows exactly this: list.sort() mutates and returns None, while sorted() leaves the input alone and returns a new list. Chapter 5.5 introduced both; this is the reason they are two functions.
9. Predict, Run, Observe
# A
def f(items):
items.append(99)
a = [1]
f(a)
print("A:", a)
# B
def g(items):
items = [99]
b = [1]
g(b)
print("B:", b)
# C
def h(d):
d["new"] = True
c = {"old": True}
h(c)
print("C:", c)
# D
def k(n):
n += 1
d = 5
k(d)
print("D:", d)The verified output:
A: [1, 99]
B: [1]
C: {'old': True, 'new': True}
D: 5A — a method changed the shared object.
B — an assignment re-pointed the local name only.
C — dictionaries behave exactly as lists do.
D — an integer is immutable, and n += 1 rebinds the local name, so nothing reaches the caller. D is B with a different type, not a different rule.
10. Exercises
Exercise 1 — predict the pair. For a function that does items.append("x") and one that does items = ["x"], predict what the caller sees after each. Verify both and explain the difference in two sentences without using the word "copy".
Exercise 2 — prove identity. Write a function that prints whether its parameter is the same object as a module-level list, using is. Call it and explain the result.
Exercise 3 — make it safe. Rewrite def add_test(suite): suite.append("burst") three ways — copying inside, returning a new list, and taking a tuple. Say which you would ship and why.
Exercise 4 — the dictionary case. Write a function that adds a key to a results dictionary, call it, and show the caller's dictionary changed. Then rewrite it so the caller's is untouched.
Exercise 5 — connect it back. In three sentences, explain the mutable-default trap using only Chapter 11.5's rule about when defaults are evaluated and this chapter's rule about what a parameter is bound to.
Exercise 6 — name the promise. For each function name, say whether it may mutate its argument: (a) count_failures (b) append_result (c) normalise (d) clear_queue (e) summarise.
Exercise 7 — sort two ways. Using tests = ["c", "a", "b"], show .sort() and sorted() side by side, printing the original after each. Say what each returned and why the library provides both.
Exercise 8 — find the surprise. This function is named as a pure query but is not. Identify the problem line, say what a caller would wrongly believe, and fix it.
def longest_name(names):
names.sort(key=len)
return names[-1]11. Summary
- A function receives the same object, not a copy.
isconfirms it. - A method that modifies the object is visible to the caller;
suite.append("burst")changes the caller's list. - An assignment inside the function is not visible;
suite = ["new"]re-points a local name only. - One object, two names is the whole model, and it is the same fact as Chapter 2.3's assignment and Chapter 10.1's loop variable.
- Dictionaries and sets behave exactly like lists. Immutable types —
int,str,tuple— cannot be affected at all. - A tuple parameter is a guarantee that the function cannot alter its input.
- The mutable-default trap is this rule plus Chapter 11.5's: one object, created once, shared by every call.
- Three defences: copy on the way in, return a new collection, or take an immutable type.
- Returning a new collection is the habit worth forming — the effect is visible at the call site and the function composes.
- Mutating is correct when it is the function's stated job, and the name must say so.
- Never both mutate and return the modified object.
list.sort()returnsNonefor exactly this reason;sorted()is the other half.
The next chapter is the module's most important distinction, and this chapter has already made half of its argument.
Where this fits
Part of the Python curriculum.
