Session 18 - Mutability & Aliasing

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

What this lesson covers

The lesson, slide by slide

1. Mutability & Aliasing

Title

Python Fundamentals - Session 18

Why b = a can change a behind your back

2. What you will be able to do

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:

  1. Explain that a name is a label bound to an object, and that b = a makes two labels for one object.
  2. Use id() and is to prove two names point at the same object.
  3. Sort types into mutable (list, dict, set) and immutable (int, str, tuple).
  1. Copy safely with list.copy(), [:], and dict.copy().
  2. Explain why a shallow copy still shares nested objects, and fix it with copy.deepcopy.
  3. Avoid the two classic traps: a function mutating the caller's list, and a mutable default argument.

3. What survived from Session 17 - Comprehensions?

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.

4. Names Are Labels

Section

Part 1

5. A name points at an object

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.

6. Break it if you can: A name points at an object

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.

7. Assignment binds; it does not copy

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.

8. By analogy: Assignment binds; it does not copy

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.

9. What has to happen first: Two names, one list

Ranking

Put in order

Put the moves of Two names, one list into the order they have to happen.

  1. Line 2 binds b to the same list
  2. Line 3 mutates that one list through b
  3. Read the output

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.

10. Two names, one 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.

lineab
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]

11. Fill in: a for Two names, one list

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.

lineab
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]

12. Restore the missing line: A dictionary with two names

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.

13. A dictionary with two names

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.

lineprices[pen]alias[pen]
alias = prices22
alias[pen] = 999999

14. What each one costs: A dictionary with two names

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.

lineprices[pen]alias[pen]
alias = prices22
alias[pen] = 999999

15. Two sticky notes on one folder

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.

16. Teach it back: Two sticky notes on 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.

17. The word for it: aliasing

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.

18. Take the definitions apart: binding vs alias

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.

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.
alias
A second name for the same object.; Aliases share every in-place change; because there is only one object underneath.
b1
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.
b2
A second name for the same object. Aliases share every in-place change, because there is only one object underneath.

19. Proving It With id()

Section

Part 2

20. id() is the object's address

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).

21. is proves they are the same object

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.

checkasksresult
id(a) == id(b)same address?True
a is bsame object?True

22. Inspect it line by line: is proves they are the same object

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.

  • So id(a) and id(b) are the same number - one object, one address.
  • Verified by execution: id(a) == id(b) is True, and a is b is True.

23. Same twin, or just look-alikes?

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.

24. == compares values; is compares identity

Concept

== asks 'do these look equal?'. is asks 'are these the exact same object?'. Two different lists can be == yet not is.

25. Predict the next row: Equal but not identical

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

expressionvalue
a == bTrue
a is bFalse
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.

26. Equal but not identical

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.

expressionvalue
a == bTrue
a is bFalse
id(a) == id(b)False

27. Watch it run: Equal but not identical

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?

  1. Step 1: expression is a == b
  2. Step 2: expression is a is b
  3. Step 3: expression is id(a) == id(b)

28. Mutable vs Immutable

Section

Part 3

29. Some objects can change in place

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).

30. Plan first: Reassigning an int makes a new object

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:

  1. y = x points y at the int 10
  2. y = y + 1 rebinds y to a brand-new int 11
  3. Read the output

31. Reassigning an int makes a new object

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.

linexy
x = 1010-
y = x1010
y = y + 11011

32. What stays fixed: Reassigning an int makes a new object

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.

  1. Step 1: line is x = 10
  2. Step 2: line is y = x
  3. Step 3: line is y = y + 1

33. Why aliasing only bites with mutables

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.

34. Where does each piece belong: Session 18 - Mutability & Aliasing

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.

Names Are Labels
A name points at an object; Assignment binds; it does not copy; Two names, one list
Proving It With id()
id() is the object's address; is proves they are the same object; Same twin, or just look-alikes?
Mutable vs Immutable
Some objects can change in place; Reassigning an int makes a new object; Why aliasing only bites with mutables
s1
Names Are Labels is where Session 18 - Mutability & Aliasing puts A name points at an object, Assignment binds; it does not copy, Two names, one list. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
Proving It With id() is where Session 18 - Mutability & Aliasing puts id() is the object's address, is proves they are the same object, Same twin, or just look-alikes?. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
Mutable vs Immutable is where Session 18 - Mutability & Aliasing puts Some objects can change in place, Reassigning an int makes a new object, Why aliasing only bites with mutables. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

35. Strings cannot be edited in place

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 writeresult
s[0] = "b"TypeError: 'str' object does not support item assignment

36. Draw the shape of it: Strings cannot be edited in place

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.

37. Tuples are immutable too

Worked example

t = (1, 2, 3)
t[0] = 9

Try 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 writeresult
t[0] = 9TypeError: 'tuple' object does not support item assignment

38. Making a Real Copy

Section

Part 4

39. To avoid sharing, copy

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.

40. Finish it with less help: list.copy() makes a separate 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.

41. list.copy() makes a separate 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.

lineaba is b
b = a.copy()[1, 2, 3][1, 2, 3]False
b.append(4)[1, 2, 3][1, 2, 3, 4]False

42. The slice [:] copies too

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.

lineab
b = a[:][1, 2, 3][1, 2, 3]
b.append(4)[1, 2, 3][1, 2, 3, 4]

43. Restore the missing line: dict.copy() for dictionaries

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.

44. dict.copy() for dictionaries

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.

lineprices[pen]copy_prices[pen]
prices.copy()22
copy_prices[pen] = 99299

45. Plan first: Contrast: alias vs copy in one run

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:

  1. b aliases a; c is a copy
  2. b's edit hits a; c's does not
  3. Read the output

46. Contrast: alias vs copy in one run

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].

lineabc
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]

47. Watch it run: Contrast: alias vs copy in one run

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?

  1. Step 1: line is c = a.copy()
  2. Step 2: line is b.append(3)
  3. Step 3: line is c.append(4)

48. Aliasing on purpose is fine

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.

49. Shallow vs Deep

Section

Part 5

50. A copy is only one level deep

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.

51. Term to definition: Session 18 - Mutability & Aliasing

Matching

Match the pairs

Match each term to the definition this lesson gave it — not the one you would guess from the word.

  • t1. binding
  • t2. alias
  • t3. id() and is
  • t4. mutable / immutable
  • t5. shallow copy
  • d1. 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.
  • d2. A second name for the same object. Aliases share every in-place change, because there is only one object underneath.
  • d3. 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).
  • d4. Mutable = can be altered in place (list, dict, set). Immutable = cannot change; any 'change' actually builds a new object (int, str, tuple).
  • d5. A new top-level container whose elements still point at the original nested objects. Only the outer layer is duplicated.

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.

52. What has to happen first: The nested list is still shared

Ranking

Put in order

Put the moves of The nested list is still shared into the order they have to happen.

  1. shallow is a new outer list
  2. But shallow[0] is outer[0] - same inner list
  3. Mutating the inner list shows in both

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.

53. The nested list is still shared

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.

checkresult
outer after append[[1, 2, 99], [3, 4]]
shallow after append[[1, 2, 99], [3, 4]]
outer[0] is shallow[0]True

54. deepcopy copies every layer

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.

55. Predict the next row: deepcopy breaks the sharing

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

checkresult
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].

56. deepcopy breaks the sharing

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.

checkresult
outer after append[[1, 2], [3, 4]]
deep after append[[1, 2, 99], [3, 4]]
outer[0] is deep[0]False

57. Fill in: result for deepcopy breaks the sharing

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.

checkresult
outer after append[[1, 2], [3, 4]]
deep after append[[1, 2, 99], [3, 4]]
outer[0] is deep[0]False

58. Something is wrong here: shallow-copying nested data

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.

59. Trap: shallow-copying nested data

Trap

The 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.

printresult
original{'scores': [10, 20, 30]}
shallow{'scores': [10, 20, 30]}

The fix

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]}.

printresult
original{'scores': [10, 20]}
deep{'scores': [10, 20, 30]}

60. Functions Mutate Your List

Section

Part 6

61. Passing an argument binds a name

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.

62. Plan first: A function changes the caller's 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:

  1. nums is bound to scores' list
  2. append mutates that shared list
  3. Read the output

63. A function changes the caller's list

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.

stepscores
before call[10, 20, 30]
inside: nums.append(0)[10, 20, 30, 0]
after call[10, 20, 30, 0]

64. Rebinding inside a function is local

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.

65. Finish it with less help: Rebind vs mutate inside a function

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.

66. Rebind vs mutate inside a function

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.

momentvalue
inside: nums[10, 20, 30, 0]
outside: scores[10, 20, 30]

67. Something is wrong here: += inside a function still mutates

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.

68. Trap: += inside a function still mutates

Trap

The 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.

printresult
scores[1, 2, 99]

The fix

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].

printresult
scores[1, 2]

69. Break it on purpose: += inside a function still mutates

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.

70. The Default Argument Trap

Section

Part 7

71. A default value is made once

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.

72. Plan first: The list that will not reset

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:

  1. cart=[] is built once at definition
  2. Each call appends to that same list
  3. Read the surprising output

73. The list that will not reset

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.

callreturns
add_item("apple")['apple']
add_item("banana")['apple', 'banana']
add_item("cherry")['apple', 'banana', 'cherry']

74. The default is baked into the recipe

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.

75. Teach it back: The default is baked into the recipe

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.

76. The fix: default to None

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.

77. By analogy: The fix: default to None

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.

78. What has to happen first: None default gives a fresh list each call

Ranking

Put in order

Put the moves of None default gives a fresh list each call into the order they have to happen.

  1. Default is None, an immutable sentinel
  2. Build a new list inside when cart is None
  3. Read the output

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'.

79. None default gives a fresh list each call

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.

callreturns
add_item("apple")['apple']
add_item("banana")['banana']

80. Draw the shape of it: None default gives a fresh list each call

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.

81. Something is wrong here: a counter that keeps counting

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.

82. Trap: a counter that keeps counting

Trap

The 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.

callprints
log("a")1
log("b")2
log("c")3

The fix

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.

callprints
log("a")1
log("b")1

83. Which of these survive contact with Session 18 - Mutability & Aliasing?

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.

Holds up
A variable is not a box that holds a value. It is a label stuck onto an object that lives elsewhere in memory.; 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.
Breaks
A dict holds a list, and .copy() feels safe.; You reach for += thinking it is a safe rebind.
sound
These are stated as this lesson states them — each one survives the edge cases Session 18 - Mutability & Aliasing puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

84. More Aliasing in the Wild

Section

Part 8

85. Dicts and sets alias the same way

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.

86. Break it if you can: Dicts and sets alias the same way

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.

87. A set with two names

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.)

linetagsalso
also = tags{red, blue}{red, blue}
also.add(green){red, blue, green}{red, blue, green}

88. Inspect it line by line: A set with two names

Error analysis

Annotate

Walk the callouts on A set with two names. Each one is a place this is easy to get subtly wrong.

  • One set, two labels - sets are mutable, so this shares like a list.
  • Verified by execution: sorted(tags) is ['blue', 'green', 'red']. (sorted just gives a stable order to print.)

89. Restore the missing line: clear() empties it for every alias

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.

90. clear() empties it for every alias

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.

linedatabackup
backup = data[1, 2, 3][1, 2, 3]
data.clear()[][]

91. What each one costs: clear() empties it for every alias

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.

linedatabackup
backup = data[1, 2, 3][1, 2, 3]
data.clear()[][]

92. Patterns & Checks

Section

Part 9

93. Copy, don't alias

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.

94. Safe function parameters

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.

95. Where this shows up: Session 18 - Mutability & Aliasing

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.

96. Check: alias append

Check

One object or two?

a = [1, 2, 3]
b = a
b.append(4)
print(a)
b = a makesa after b.append(4)
an alias?

Check your understanding

What does this print?

  • A. [1, 2, 3, 4] (correct)
  • B. [1, 2, 3]
  • C. [4]
  • D. [1, 2, 3, 4, 4]

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.

Why B tempts people
That would need b to be a copy. b = a is an alias, so a and b are the same list.
Why C tempts people
append adds to the existing list; it does not replace it with [4].
Why D tempts people
append runs once, adding a single 4 to one shared list - not two 4s.

97. Check: copy independence

Check

Now with a real copy.

a = [1, 2, 3]
b = a.copy()
b.append(4)
print(a)
b = a.copy() makesa after b.append(4)
a new list?

Check your understanding

What does this print?

  • A. [1, 2, 3] (correct)
  • B. [1, 2, 3, 4]
  • C. [1, 2, 3, 4, 4]
  • D. []

Answer: A

Why: a.copy() builds a separate list, so appending to b cannot touch a. a stays [1, 2, 3]. Verified by execution.

Why B tempts people
That is b's value. Because b is a copy, a is unchanged at [1, 2, 3].
Why C tempts people
Only b grows, and only by one element; a never changes at all.
Why D tempts people
copy() duplicates the contents, so a is not emptied - it keeps [1, 2, 3].

98. Check: == vs is

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?

  • A. True False (correct)
  • B. True True
  • C. False False
  • D. False True

Answer: A

Why: The lists have equal contents, so == is True; but they are two separate objects, so is is False. Verified by execution.

Why B tempts people
is would be True only if a and b were the same object. Typed-out literals build two objects.
Why C tempts people
== compares contents, which match here, so == is True - not False.
Why D tempts people
This reverses both: equal contents give == True, and separate objects give is False.

99. Check: shallow copy nested

Check

A copy of a list of lists.

outer = [[1, 2], [3, 4]]
s = outer.copy()
s[0].append(9)
print(outer)
copy typeouter[0] shared?
shallow?

Check your understanding

What does this print?

  • A. [[1, 2, 9], [3, 4]] (correct)
  • B. [[1, 2], [3, 4]]
  • C. [[1, 2], [3, 4, 9]]
  • D. [[1, 2, 9], [3, 4, 9]]

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.

Why B tempts people
The inner list is shared, so the append does reach outer - it does not stay unchanged.
Why C tempts people
append(9) targets index 0, the first inner list, not the second.
Why D tempts people
Only s[0] is appended to; the second inner list is never touched.

100. Check: function side effect

Check

Does the caller's list change?

def add(nums):
    nums.append(0)

scores = [1, 2]
add(scores)
print(scores)
nums isscores after
an alias of scores?

Check your understanding

What does this print?

  • A. [1, 2, 0] (correct)
  • B. [1, 2]
  • C. None
  • D. [0]

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.

Why B tempts people
The list is passed by binding, not copied, so the in-place append does reach scores.
Why C tempts people
print(scores) shows the list, not the function's return value - scores is a real list.
Why D tempts people
append adds to the existing contents; it does not replace the list with [0].

101. Check: mutable default

Check

The default list is made once.

def f(x, acc=[]):
    acc.append(x)
    return acc

f(1)
print(f(2))
callacc
f(1)[1]
f(2)?

Check your understanding

What does the print show?

  • A. [1, 2] (correct)
  • B. [2]
  • C. [1]
  • D. [1, 1, 2]

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.

Why B tempts people
acc does not reset between calls - the earlier 1 is still there, so it is [1, 2].
Why C tempts people
f(2) appends 2 to the shared default, so the 2 is included.
Why D tempts people
Each call appends its own x exactly once; f(1) added one 1, not two.

102. Fill in: acc for Check: mutable default

Comparison

Comparison matrix

From Check: mutable default: refill the acc column from what you know. The rest of the table is as it appeared.

callacc
f(1)[1]
f(2)?

103. Connect it up: Session 18 - Mutability & Aliasing

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.

104. What you can do now

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 writeIt means
b = aalias: two names, one object
b = a.copy() / a[:]a new, independent list
copy.deepcopy(a)clone every nested layer too
a is bsame 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.

Sources

  1. Python 3 FAQ - How do I write a function with output parameters (call by reference)?
  2. Python 3 FAQ - Why are default values shared between objects?
  3. Python 3 Library - copy: Shallow and deep copy operations
  4. All snippets and error messages executed and copied from CPython 3.12. — Author verification run, 2026-07-15 (Python Fundamentals series, Session 18).

Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108