Session 18 of the Python Fundamentals series, covered in depth, and the session about hidden bugs. A name is a label bound to an object, so `b = a` gives you two names for ONE list, and mutating through either one shows through the other. You prove that two names refer to the same object with `id()` and `is`, sort the mutable types (list, dict, set) from the immutable ones (int, str, tuple), and copy safely with `list.copy()`, `[:]`, and `dict.copy()` - then see why a shallow copy still shares nested objects, which `copy.deepcopy` fixes. It ends on the two classic traps: passing a list into a function mutates the caller's list, and a mutable default argument such as `def f(x, acc=[])` quietly accumulates across calls. Every snippet and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 104 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 18
Why b = a can change a behind your back
Objectives
You have used lists and dicts for sessions. Now we look at what a name really is - and the bugs that come from missing it. By the end you can:
b = a makes two labels for one object.id() and is to prove two names point at the same object.list.copy(), [:], and dict.copy().copy.deepcopy.Warm-up
Discussion prompt
Before we open Session 18 - Mutability & Aliasing: without looking back, what was the main idea of Session 17 - Comprehensions, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 17 of the Python Fundamentals series, in depth. Building a whole list in one line with a comprehension: [expr for x in it], adding a filter with if, and rewriting an accumulate-in-a-loop pattern as a comprehension.
Section
Part 1
Concept
A variable is not a box that holds a value. It is a label stuck onto an object that lives elsewhere in memory.
binding — Attaching a name to an object. a = [1, 2, 3] binds the name a to a list object. The name is not the object - it just points at it.
Counterexample
Discussion prompt
A variable is not a box that holds a value. It is a label stuck onto an object that lives elsewhere in memory.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Concept
b = a does not make a new object. It sticks a second label, b, onto the same object a already points at.
Two names, one object. Change the object through either name and both names see the change.
Analogy
Discussion prompt
Explain Assignment binds; it does not copy by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
b = a does not make a new object. It sticks a second label, b, onto the same object a already points at.
Ranking
Put in order
Put the moves of Two names, one list into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. No new list is made - b is just a second label on a's list.
Worked example
a = [1, 2, 3]
b = a
b.append(4)
print(a)
print(b)Line 2 binds b to the same list
Why: No new list is made - b is just a second label on a's list.
Line 3 mutates that one list through b
Why: append changes the object in place; a points at the same object, so a sees it too.
Read the output
Why: Verified by execution: both print [1, 2, 3, 4]. One list, two labels.
| line | a | b |
|---|---|---|
| a = [1, 2, 3] | [1, 2, 3] | - |
| b = a | [1, 2, 3] | [1, 2, 3] |
| b.append(4) | [1, 2, 3, 4] | [1, 2, 3, 4] |
Comparison
Comparison matrix
From Two names, one list: refill the a column from what you know. The rest of the table is as it appeared.
| line | a | b |
|---|---|---|
| a = [1, 2, 3] | [1, 2, 3] | - |
| b = a | [1, 2, 3] | [1, 2, 3] |
| b.append(4) | [1, 2, 3, 4] | [1, 2, 3, 4] |
Fill the middle
Fill in the blanks
From A dictionary with two names — one line has had its right-hand side removed. Put it back.
prices = prices
alias = ___
alias["pen"] = 99
print(prices)
Why: alias is what everything below it consumes, so the wrong expression here fails later and somewhere else. No new dict is made - alias and prices point at one dictionary.
Worked example
prices = {"pen": 2}
alias = prices
alias["pen"] = 99
print(prices)alias = prices binds a second name
Why: No new dict is made - alias and prices point at one dictionary.
Editing through alias shows in prices
Why: Verified by execution: prices prints {'pen': 99}. Dicts alias exactly like lists.
| line | prices[pen] | alias[pen] |
|---|---|---|
| alias = prices | 2 | 2 |
| alias[pen] = 99 | 99 | 99 |
Trade off
Comparison matrix
From A dictionary with two names: every row here is a choice with a cost. Fill the prices[pen] column, then say which row you would actually pick and what you give up for it.
| line | prices[pen] | alias[pen] |
|---|---|---|
| alias = prices | 2 | 2 |
| alias[pen] = 99 | 99 | 99 |
Intuition
Picture a physical folder on a desk. A name is a sticky note with an arrow pointing at that folder.
b = a puts a second sticky note on the same folder - it does not photocopy it. If you add a page while holding the note labeled b, the person reading note a finds that page too. There is only ever one folder.
Explain it
Discussion prompt
Explain Two sticky notes on one folder to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Picture a physical folder on a desk. A name is a sticky note with an arrow pointing at that folder.
Concept
When two names point at one object, they are aliases. Mutating through one alias is visible through every other alias.
alias — A second name for the same object. Aliases share every in-place change, because there is only one object underneath.
Definition probe
Sort into buckets
Every line below is part of the definition of binding or of alias — one or the other, never both. Put each where it belongs.
a = [1, 2, 3] binds the name a to a list object.; The name is not the object - it just points at it.a = [1, 2, 3] binds the name a to a list object. The name is not the object - it just points at it.Section
Part 2
Concept
id(x) returns a number identifying the object x points at - think of it as the object's address. Same address means same object.
id() and is — id(x) gives an object's identity number. x is y is True when x and y point at the same object - equivalent to id(x) == id(y).
Worked example
a = [1, 2, 3]
b = a
print(id(a) == id(b))
print(a is b)b = a bound b to a's object
Why: So id(a) and id(b) are the same number - one object, one address.
Both checks report the same object
Why: Verified by execution: id(a) == id(b) is True, and a is b is True.
| check | asks | result |
|---|---|---|
| id(a) == id(b) | same address? | True |
| a is b | same object? | True |
Error analysis
Annotate
Walk the callouts on is proves they are the same object. Each one is a place this is easy to get subtly wrong.
Intuition
== is like asking 'do these two people look the same?'. is is like asking 'are these two names for the exact same person?'.
Identical twins are == but not is - two separate people who match. An alias is is - one person with two names. When a bug asks 'is this the same object I changed elsewhere?', that is an is question.
Concept
== asks 'do these look equal?'. is asks 'are these the exact same object?'. Two different lists can be == yet not is.
Pattern
Predict first
The table runs: a == b | True · a is b | False
In Equal but not identical, given the rows so far: what is the next one — the row where expression is id(a) == id(b)?
Correct: id(a) == id(b) | False
| expression | value |
|---|---|
| a == b | True |
| a is b | False |
| id(a) == id(b) | False |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Each [1, 2, 3] builds a fresh object; no aliasing here.
Worked example
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b)
print(a is b)
print(id(a) == id(b))a and b are two separate lists typed out
Why: Each [1, 2, 3] builds a fresh object; no aliasing here.
Same contents, different objects
Why: Verified by execution: a == b is True (same contents), but a is b is False and the ids differ.
| expression | value |
|---|---|
| a == b | True |
| a is b | False |
| id(a) == id(b) | False |
Pattern
Step through it
Step through Equal but not identical one row at a time. What is driving the change, and what would the row after the last one be?
Section
Part 3
Concept
A mutable object can be changed after it is made: list, dict, set. An immutable object cannot: int, str, float, bool, tuple.
mutable / immutable — Mutable = can be altered in place (list, dict, set). Immutable = cannot change; any 'change' actually builds a new object (int, str, tuple).
Step zero
Discussion prompt
Reassigning an int makes a new object — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: y = x points y at the int 10
Answer:
Worked example
x = 10
y = x
y = y + 1
print(x)
print(y)y = x points y at the int 10
Why: Both names point at the same immutable int for now.
y = y + 1 rebinds y to a brand-new int 11
Why: You cannot change 10 into 11 in place - ints are immutable. y just gets a new label; x still points at 10.
Read the output
Why: Verified by execution: x is 10, y is 11. Rebinding one name never touches the other for immutables.
| line | x | y |
|---|---|---|
| x = 10 | 10 | - |
| y = x | 10 | 10 |
| y = y + 1 | 10 | 11 |
Invariant
Step through it
Step through Reassigning an int makes a new object one row at a time. One of these columns never changes — find it, and say why it cannot.
Concept
Immutable objects can never change in place, so an alias to one can never surprise you - any 'update' quietly makes a new object and rebinds.
Aliasing bugs live entirely in the mutable world: lists, dicts, and sets - the things you can edit in place.
Sorting
Sort into buckets
These are the pieces of Session 18 - Mutability & Aliasing, out of order. Put each one back under the part of the lesson it belongs to.
Worked example
s = "cat"
s[0] = "b"Try to change the first character
Why: s[0] = "b" asks to edit the string in place - but str is immutable.
Python refuses
Why: Verified by execution: it raises TypeError. To 'change' a string you build a new one, e.g. "b" + s[1:].
| you write | result |
|---|---|
| s[0] = "b" | TypeError: 'str' object does not support item assignment |
Blank canvas
Draw it
Draw what Strings cannot be edited in place just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Worked example
t = (1, 2, 3)
t[0] = 9Try to overwrite an element
Why: A tuple looks like a list but cannot be edited after it is built.
Python refuses
Why: Verified by execution: TypeError, same shape as the string one. Need a change? Build a new tuple with t + (4,).
| you write | result |
|---|---|
| t[0] = 9 | TypeError: 'tuple' object does not support item assignment |
Section
Part 4
Concept
When you want an independent list, do not write b = a. Ask for a copy: a.copy() or the slice a[:]. Both build a new list.
Faded example
Fill in the blanks
list.copy() makes a separate list, with the scaffolding fading: two lines are gone now — fill both.
a = [1, 2, 3]
b = a.copy()
b.append(4)
print(a)
print(b)
print(a is b)
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. b points at a fresh object with the same contents - not at a's list.
Worked example
a = [1, 2, 3]
b = a.copy()
b.append(4)
print(a)
print(b)
print(a is b)b = a.copy() builds a new list
Why: b points at a fresh object with the same contents - not at a's list.
Mutating b leaves a untouched
Why: Verified by execution: a stays [1, 2, 3], b is [1, 2, 3, 4], and a is b is False.
| line | a | b | a is b |
|---|---|---|---|
| b = a.copy() | [1, 2, 3] | [1, 2, 3] | False |
| b.append(4) | [1, 2, 3] | [1, 2, 3, 4] | False |
Worked example
a = [1, 2, 3]
b = a[:]
b.append(4)
print(a)
print(b)a[:] is a full-length slice
Why: Slicing always produces a new list; [:] takes every element, so it is a shortcut copy.
Same independence as .copy()
Why: Verified by execution: a is [1, 2, 3], b is [1, 2, 3, 4]. Pick whichever reads clearer to you.
| line | a | b |
|---|---|---|
| b = a[:] | [1, 2, 3] | [1, 2, 3] |
| b.append(4) | [1, 2, 3] | [1, 2, 3, 4] |
Fill the middle
Fill in the blanks
From dict.copy() for dictionaries — one line has had its right-hand side removed. Put it back.
prices = prices.copy()
copy_prices = ___
copy_prices["pen"] = 99
print(prices)
print(copy_prices)
Why: copy_prices is what everything below it consumes, so the wrong expression here fails later and somewhere else. copy_prices is a separate dictionary with the same key/value pairs.
Worked example
prices = {"pen": 2, "book": 5}
copy_prices = prices.copy()
copy_prices["pen"] = 99
print(prices)
print(copy_prices)dict.copy() builds a new dict
Why: copy_prices is a separate dictionary with the same key/value pairs.
Editing one leaves the other alone
Why: Verified by execution: prices keeps pen at 2; only copy_prices changes to 99.
| line | prices[pen] | copy_prices[pen] |
|---|---|---|
| prices.copy() | 2 | 2 |
| copy_prices[pen] = 99 | 2 | 99 |
Step zero
Discussion prompt
Contrast: alias vs copy in one run — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: b aliases a; c is a copy
Answer:
Worked example
a = [1, 2]
b = a
c = a.copy()
b.append(3)
c.append(4)
print(a)
print(b)
print(c)b aliases a; c is a copy
Why: b points at a's list; c points at a fresh list.
b's edit hits a; c's does not
Why: b.append(3) changes the shared list (a and b). c.append(4) only touches c's own list.
Read the output
Why: Verified by execution: a and b are [1, 2, 3], c is [1, 2, 4].
| line | a | b | c |
|---|---|---|---|
| c = a.copy() | [1, 2] | [1, 2] | [1, 2] |
| b.append(3) | [1, 2, 3] | [1, 2, 3] | [1, 2] |
| c.append(4) | [1, 2, 3] | [1, 2, 3] | [1, 2, 4] |
Pattern
Step through it
Step through Contrast: alias vs copy in one run one row at a time. What is driving the change, and what would the row after the last one be?
Concept
Sharing is not always a bug. Sometimes you want two names for one object - a short name for a deeply nested item you plan to mutate.
The rule is intent: alias when you mean to share, copy when you mean to keep them independent. Bugs come from copying by accident (b = a) when you needed a real copy.
Section
Part 5
Concept
.copy() and [:] make a shallow copy: a new outer list, but the inner objects are still shared. The new list holds the same nested objects.
shallow copy — A new top-level container whose elements still point at the original nested objects. Only the outer layer is duplicated.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
a = [1, 2, 3] binds the name a to a list object. The name is not the object - it just points at it.x is y is True when x and y point at the same object - equivalent to id(x) == id(y).Why: These are the working definitions of binding, alias, id() and is, mutable / immutable, shallow copy as Session 18 - Mutability & Aliasing uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Ranking
Put in order
Put the moves of The nested list is still shared into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. outer is shallow would be False - the outer container was copied.
Worked example
outer = [[1, 2], [3, 4]]
shallow = outer.copy()
shallow[0].append(99)
print(outer)
print(shallow)
print(outer[0] is shallow[0])shallow is a new outer list
Why: outer is shallow would be False - the outer container was copied.
But shallow[0] is outer[0] - same inner list
Why: Only the outer layer was duplicated; both outer lists hold the same inner [1, 2] object.
Mutating the inner list shows in both
Why: Verified by execution: both print [[1, 2, 99], [3, 4]], and outer[0] is shallow[0] is True.
| check | result |
|---|---|
| outer after append | [[1, 2, 99], [3, 4]] |
| shallow after append | [[1, 2, 99], [3, 4]] |
| outer[0] is shallow[0] | True |
Concept
copy.deepcopy(x) recursively copies the object and everything nested inside it. Nothing is shared - it is a fully independent clone.
Import it first: import copy, then copy.deepcopy(...). Reach for it only when you have nested mutable objects to protect.
Pattern
Predict first
The table runs: outer after append | [[1, 2], [3, 4]] · deep after append | [[1, 2, 99], [3, 4]]
In deepcopy breaks the sharing, given the rows so far: what is the next one — the row where check is outer[0] is deep[0]?
Correct: outer[0] is deep[0] | False
| check | result |
|---|---|
| outer after append | [[1, 2], [3, 4]] |
| deep after append | [[1, 2, 99], [3, 4]] |
| outer[0] is deep[0] | False |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. deep[0] is a brand-new inner list, not outer[0].
Worked example
import copy
outer = [[1, 2], [3, 4]]
deep = copy.deepcopy(outer)
deep[0].append(99)
print(outer)
print(deep)
print(outer[0] is deep[0])deepcopy clones every nested list too
Why: deep[0] is a brand-new inner list, not outer[0].
Now the inner edit is isolated
Why: Verified by execution: outer stays [[1, 2], [3, 4]], deep is [[1, 2, 99], [3, 4]], and outer[0] is deep[0] is False.
| check | result |
|---|---|
| outer after append | [[1, 2], [3, 4]] |
| deep after append | [[1, 2, 99], [3, 4]] |
| outer[0] is deep[0] | False |
Comparison
Comparison matrix
From deepcopy breaks the sharing: refill the result column from what you know. The rest of the table is as it appeared.
| check | result |
|---|---|
| outer after append | [[1, 2], [3, 4]] |
| deep after append | [[1, 2, 99], [3, 4]] |
| outer[0] is deep[0] | False |
Anomaly
Predict first
A student writes this, and it looks reasonable:
A dict holds a list, and .copy() feels safe.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: dict.copy() duplicated the outer dict, but 'scores' in both points at the SAME list.
Use deepcopy when the data is nested.
Why: dict.copy() duplicated the outer dict, but 'scores' in both points at the SAME list. Appending through one shows in both.
Trap
A dict holds a list, and .copy() feels safe.
original = {"scores": [10, 20]}
shallow = original.copy()
shallow["scores"].append(30)
print(original)
print(shallow)Both dicts share one inner list
Why: dict.copy() duplicated the outer dict, but 'scores' in both points at the SAME list. Appending through one shows in both.
| result | |
|---|---|
| original | {'scores': [10, 20, 30]} |
| shallow | {'scores': [10, 20, 30]} |
Use deepcopy when the data is nested.
import copy
original = {"scores": [10, 20]}
deep = copy.deepcopy(original)
deep["scores"].append(30)
print(original)
print(deep)Each dict has its own inner list
Why: deepcopy cloned the nested list too, so original is untouched. Verified: original stays {'scores': [10, 20]}.
| result | |
|---|---|
| original | {'scores': [10, 20]} |
| deep | {'scores': [10, 20, 30]} |
Section
Part 6
Concept
Calling f(scores) binds the parameter inside f to the same object scores points at. The parameter is an alias.
So if the function mutates that list in place, the caller's list changes too. There is only one list.
Step zero
Discussion prompt
A function changes the caller's list — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: nums is bound to scores' list
Answer:
Worked example
def append_zero(nums):
nums.append(0)
scores = [10, 20, 30]
append_zero(scores)
print(scores)nums is bound to scores' list
Why: The call passes the object, not a copy - nums and scores alias one list.
append mutates that shared list
Why: nums.append(0) changes the object in place; scores sees it because it is the same object.
Read the output
Why: Verified by execution: scores is now [10, 20, 30, 0]. The function had a side effect on the caller.
| step | scores |
|---|---|
| before call | [10, 20, 30] |
| inside: nums.append(0) | [10, 20, 30, 0] |
| after call | [10, 20, 30, 0] |
Concept
Mutating (nums.append(...)) affects the caller. But rebinding (nums = nums + [...]) just repoints the local name - the caller's list is untouched.
In-place change leaks out; assignment to the parameter name stays inside. This is the whole distinction.
Faded example
Fill in the blanks
Rebind vs mutate inside a function, with the scaffolding fading: two lines are gone now — fill both.
def rebind(nums):
nums = nums + [0]
print("inside:", nums)
scores = [10, 20, 30]
rebind(scores)
print("outside:", scores)
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. The + makes a new list and rebinds the local name nums to it.
Worked example
def rebind(nums):
nums = nums + [0]
print("inside:", nums)
scores = [10, 20, 30]
rebind(scores)
print("outside:", scores)nums = nums + [0] builds a NEW list
Why: The + makes a new list and rebinds the local name nums to it. scores still points at the original.
The caller's list is unchanged
Why: Verified by execution: inside prints [10, 20, 30, 0], but outside is still [10, 20, 30]. Rebinding stayed local.
| moment | value |
|---|---|
| inside: nums | [10, 20, 30, 0] |
| outside: scores | [10, 20, 30] |
Anomaly
Predict first
A student writes this, and it looks reasonable:
You reach for += thinking it is a safe rebind.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: For lists, nums += [99] is the same as nums.extend([99]) - it mutates the shared object, so the caller changes.
To leave the caller alone, build a new list with +.
Why: For lists, nums += [99] is the same as nums.extend([99]) - it mutates the shared object, so the caller changes.
Trap
You reach for += thinking it is a safe rebind.
def grow(nums):
nums += [99]
scores = [1, 2]
grow(scores)
print(scores)On a list, += is in-place extend
Why: For lists, nums += [99] is the same as nums.extend([99]) - it mutates the shared object, so the caller changes.
| result | |
|---|---|
| scores | [1, 2, 99] |
To leave the caller alone, build a new list with +.
def grow(nums):
nums = nums + [99]
scores = [1, 2]
grow(scores)
print(scores)nums = nums + [99] rebinds locally
Why: The + builds a new list and points the local name at it; scores is untouched. Verified: scores stays [1, 2].
| result | |
|---|---|
| scores | [1, 2] |
Break the constraint
Discussion prompt
The rule this trap just fixed:
The + builds a new list and points the local name at it; scores is untouched. Verified: scores stays [1, 2].
Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?
Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.
Answer:
For lists, nums += [99] is the same as nums.extend([99]) - it mutates the shared object, so the caller changes.
Section
Part 7
Concept
The default in def f(x, acc=[]) is created one time, when the function is defined - not fresh on each call. Every call that omits acc shares that same one list.
Step zero
Discussion prompt
The list that will not reset — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: cart=[] is built once at definition
Answer:
Worked example
def add_item(item, cart=[]):
cart.append(item)
return cart
print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry"))cart=[] is built once at definition
Why: There is a single default list, reused by every call that omits cart.
Each call appends to that same list
Why: It never resets to empty; it just keeps growing across calls.
Read the surprising output
Why: Verified by execution: ['apple'], then ['apple', 'banana'], then ['apple', 'banana', 'cherry']. The items accumulate.
| call | returns |
|---|---|
| add_item("apple") | ['apple'] |
| add_item("banana") | ['apple', 'banana'] |
| add_item("cherry") | ['apple', 'banana', 'cherry'] |
Intuition
Think of the default [] as being written into the function's recipe card once, the day you define it - not re-made each time someone cooks from the card.
So every call that leans on the default reaches for the same bowl and stirs in more. That is why leftovers from an earlier call are still sitting there when the next call starts.
Explain it
Discussion prompt
Explain The default is baked into the recipe to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Think of the default [] as being written into the function's recipe card once, the day you define it - not re-made each time someone cooks from the card.
Concept
Use an immutable sentinel. Default the parameter to None, then build a fresh list inside the body when it is None.
Now every call that omits the argument gets its own new list - the accumulation bug is gone.
Analogy
Discussion prompt
Explain The fix: default to None by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Use an immutable sentinel. Default the parameter to None, then build a fresh list inside the body when it is None.
Ranking
Put in order
Put the moves of None default gives a fresh list each call into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. None cannot accumulate; it just signals 'no cart was given'.
Worked example
def add_item(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cart
print(add_item("apple"))
print(add_item("banana"))Default is None, an immutable sentinel
Why: None cannot accumulate; it just signals 'no cart was given'.
Build a new list inside when cart is None
Why: Each omitting call makes its own empty list, so nothing carries over.
Read the output
Why: Verified by execution: ['apple'], then ['banana']. Independent lists, as expected.
| call | returns |
|---|---|
| add_item("apple") | ['apple'] |
| add_item("banana") | ['banana'] |
Blank canvas
Draw it
Draw what None default gives a fresh list each call just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Anomaly
Predict first
A student writes this, and it looks reasonable:
A history list as a default quietly persists.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: It never empties, so len keeps climbing - the count leaks between unrelated calls.
Default to None and make the list inside.
Why: It never empties, so len keeps climbing - the count leaks between unrelated calls.
Trap
A history list as a default quietly persists.
def log(msg, history=[]):
history.append(msg)
return len(history)
print(log("a"))
print(log("b"))
print(log("c"))history is one shared list across calls
Why: It never empties, so len keeps climbing - the count leaks between unrelated calls.
| call | prints |
|---|---|
| log("a") | 1 |
| log("b") | 2 |
| log("c") | 3 |
Default to None and make the list inside.
def log(msg, history=None):
if history is None:
history = []
history.append(msg)
return len(history)
print(log("a"))
print(log("b"))Each call starts with a fresh history
Why: No shared state, so the count is right per call. Verified: prints 1, then 1.
| call | prints |
|---|---|
| log("a") | 1 |
| log("b") | 1 |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
b = a does not make a new object. It sticks a second label, b, onto the same object a already points at.; Picture a physical folder on a desk. A name is a sticky note with an arrow pointing at that folder.Section
Part 8
Concept
Everything so far applies to every mutable type. alias = my_dict or also = my_set binds a second name to the same object - mutate through one, see it in both.
Counterexample
Discussion prompt
Everything so far applies to every mutable type. alias = my_dict or also = my_set binds a second name to the same object - mutate through one, see it in both.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Worked example
tags = {"red", "blue"}
also = tags
also.add("green")
print(sorted(tags))also aliases tags
Why: One set, two labels - sets are mutable, so this shares like a list.
Adding through also shows in tags
Why: Verified by execution: sorted(tags) is ['blue', 'green', 'red']. (sorted just gives a stable order to print.)
| line | tags | also |
|---|---|---|
| also = tags | {red, blue} | {red, blue} |
| also.add(green) | {red, blue, green} | {red, blue, green} |
Error analysis
Annotate
Walk the callouts on A set with two names. Each one is a place this is easy to get subtly wrong.
Fill the middle
Fill in the blanks
From clear() empties it for every alias — one line has had its right-hand side removed. Put it back.
data = [1, 2, 3]
backup = data
data.clear()
print(data)
print(backup)
Why: backup is what everything below it consumes, so the wrong expression here fails later and somewhere else. But backup = data is an alias, not a copy - both names point at one list.
Worked example
data = [1, 2, 3]
backup = data
data.clear()
print(data)
print(backup)backup was meant as a safety copy
Why: But backup = data is an alias, not a copy - both names point at one list.
clear() empties the shared list
Why: Verified by execution: both print []. The 'backup' vanished with the original. Use data.copy() to actually back it up.
| line | data | backup |
|---|---|---|
| backup = data | [1, 2, 3] | [1, 2, 3] |
| data.clear() | [] | [] |
Trade off
Comparison matrix
From clear() empties it for every alias: every row here is a choice with a cost. Fill the backup column, then say which row you would actually pick and what you give up for it.
| line | data | backup |
|---|---|---|
| backup = data | [1, 2, 3] | [1, 2, 3] |
| data.clear() | [] | [] |
Section
Part 9
Pattern
1. Want a second name for the same object? b = a is fine
Why: Aliasing is a feature when you intend shared state.
2. Want an independent list/dict/set? copy it
Why: Use a.copy() or a[:] for lists; d.copy() for dicts. b = a shares; these duplicate.
3. Nested mutable data? copy.deepcopy
Why: A shallow copy still shares the inner objects; deepcopy clones every layer.
4. Prove it with is / id() when unsure
Why: a is b (or id(a) == id(b)) True means one object; False means two.
Pattern
1. Mutating a passed-in list changes the caller's list
Why: The parameter aliases the argument. Decide on purpose whether you want that side effect.
2. To avoid the side effect, copy at the top
Why: nums = nums.copy() inside the function gives you a private list to edit.
3. Never default a parameter to [] or {}
Why: The default is made once and shared across calls - it accumulates.
4. Default to None, then build the container inside
Why: if x is None: x = [] gives each call a fresh object.
Real world
Discussion prompt
Outside this lesson: where does Session 18 - Mutability & Aliasing actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Safe function parameters is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 18 of the Python Fundamentals series, in depth. The hidden-bug session: a name is a label bound to an object, so b = a makes two names for ONE list - mutating through one shows through the other.
Check
One object or two?
a = [1, 2, 3]
b = a
b.append(4)
print(a)| b = a makes | a after b.append(4) |
|---|---|
| an alias | ? |
Check your understanding
What does this print?
Answer: A
Why: b = a binds b to the same list, so b.append(4) mutates the one shared object and a sees it. Verified by execution.
Check
Now with a real copy.
a = [1, 2, 3]
b = a.copy()
b.append(4)
print(a)| b = a.copy() makes | a after b.append(4) |
|---|---|
| a new list | ? |
Check your understanding
What does this print?
Answer: A
Why: a.copy() builds a separate list, so appending to b cannot touch a. a stays [1, 2, 3]. Verified by execution.
Check
Two lists typed out separately.
a = [1, 2]
b = [1, 2]
print(a == b, a is b)| == | is |
|---|---|
| ? | ? |
Check your understanding
What does this print?
Answer: A
Why: The lists have equal contents, so == is True; but they are two separate objects, so is is False. Verified by execution.
Check
A copy of a list of lists.
outer = [[1, 2], [3, 4]]
s = outer.copy()
s[0].append(9)
print(outer)| copy type | outer[0] shared? |
|---|---|
| shallow | ? |
Check your understanding
What does this print?
Answer: A
Why: copy() is shallow: s is a new outer list, but s[0] is still the same inner list as outer[0], so appending 9 shows in outer. Verified by execution.
Check
Does the caller's list change?
def add(nums):
nums.append(0)
scores = [1, 2]
add(scores)
print(scores)| nums is | scores after |
|---|---|
| an alias of scores | ? |
Check your understanding
What does this print?
Answer: A
Why: nums is bound to the same list as scores, so nums.append(0) mutates it in place and scores becomes [1, 2, 0]. Verified by execution.
Check
The default list is made once.
def f(x, acc=[]):
acc.append(x)
return acc
f(1)
print(f(2))| call | acc |
|---|---|
| f(1) | [1] |
| f(2) | ? |
Check your understanding
What does the print show?
Answer: A
Why: The default acc is created once and shared. f(1) leaves it [1]; f(2) appends to the same list, giving [1, 2]. Verified by execution.
Comparison
Comparison matrix
From Check: mutable default: refill the acc column from what you know. The rest of the table is as it appeared.
| call | acc |
|---|---|
| f(1) | [1] |
| f(2) | ? |
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Names Are Labels · Proving It With id() · Mutable vs Immutable · Making a Real Copy · Shallow vs Deep · Functions Mutate Your List. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
A name is a label on an object. b = a makes two labels for one object, so mutating through one is visible through the other. id() and is prove sameness; == only compares contents.
| You write | It means |
|---|---|
| b = a | alias: two names, one object |
| b = a.copy() / a[:] | a new, independent list |
| copy.deepcopy(a) | clone every nested layer too |
| a is b | same object? (id(a) == id(b)) |
| def f(x, acc=None) | safe default; build [] inside if None |
Mutable (list, dict, set) shares; immutable (int, str, tuple) cannot bite. Copy when you want independence, deepcopy when it is nested, and never default a parameter to []. Next session we build on this with list comprehensions and cleaner data transforms.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.