Module 8 · Python Under the Hood ⏱ 19 min

Mutability, References & Memory

By the end of this lesson you will be able to:
  • Distinguish mutable from immutable types and predict which operations create new objects
  • Explain pass-by-object-reference and why two names can share one list
  • Use defensive copies to stop a function from mutating a caller's data

Change a number and nothing else moves. Change a list, and suddenly another variable elsewhere in your program reports the same edit — even though you never touched it. That is not a bug in Python; it is the single most important idea in how the language handles data, and it catches every newcomer once.

The difference comes down to one word: mutability. Some objects can be altered in place after they are created; others cannot. Knowing which is which — and what happens when you hand one to a function — is what separates code that quietly corrupts its own data from code you can actually trust.

This lesson makes the model explicit. Once you can picture where an object lives and who else holds a reference to it, the confusing behaviours stop being confusing.

flowchart LR
  A["a = [1, 2, 3]"] --> O["shared list object"]
  B["b = a"] --> O
  O --> M["b.append(4) changes it<br/>for a as well"]
  style O fill:#3776ab,color:#fff
  style M fill:#b45309,color:#fff
A second assignment never copies. It just gives the same object a second name, so editing through either name changes the one shared list.

Mutable versus immutable

In Python every value is an object, and every name is a label stuck onto one. Integers, floats, strings, and tuples are immutable — once built, their value cannot change. Any operation that looks like it changes one actually constructs a fresh object and re-points the name at it.

Lists, dictionaries, and sets are mutable. You can append, pop, or reassign a key, and the same object now holds different contents.

You can watch the difference with id(), which reports an object's identity — roughly, where it sits in memory. Rebinding a name to a new value usually changes its id; mutating a container does not.

flowchart LR
  subgraph I["immutable"]
    I1["int float str<br/>tuple bool frozenset"]
  end
  subgraph M["mutable"]
    M1["list dict set"]
  end
  style I1 fill:#3776ab,color:#fff
  style M1 fill:#b45309,color:#fff
Memorise the split: immutable types build a new object on every change, mutable types change in place.
Rebinding an integer moves the name to a new object; appending to a list keeps the same object.
n = 10
print("n before:", id(n))
n = n + 5            # builds a NEW integer object
print("n after: ", id(n))

letters = ["a"]
print("letters before:", id(letters))
letters.append("b")   # mutates the SAME list object
print("letters after: ", id(letters))

Rebinding versus mutating

Watch the assignment operator closely, because = does two different jobs. Writing n = n + 5 rebinds the name n to a brand-new integer; the old object is untouched. Writing nums.append(5) mutates the list nums already labels; no new object appears, and every other name on that object sees the change at once.

The augmented operator += makes this treacherous. On a list, nums += [4] mutates in place. On an integer, n += 1 rebinds. Same symbol, opposite effect — because integers are immutable and lists are not. When one line refuses to behave like its neighbour, mutability is usually why.

`+=` mutates a list (an alias sees it) but rebinds an integer.
nums = [1, 2, 3]
alias = nums
nums += [4]          # mutates the SAME list object
print("alias sees it too:", alias)

total = 0
total += 10          # rebinds total to a new integer
print("total:", total)

Pass-by-object-reference

When you call a function and hand it an argument, Python does not copy the value. It passes the reference — the function's parameter becomes another label on the very same object.

For an immutable argument that is harmless: the function cannot alter it, so the caller is safe. For a mutable argument it is powerful and dangerous at once, because a function that appends to the list it received is editing the caller's list in place, whether the caller wanted that or not.

This is why so many list methods — append, sort, reverse — return None. They change the object itself rather than producing a new one. Mixing up a mutating method with one that returns a fresh value is a frequent source of None errors.

flowchart TD
  C["caller<br/>cart = ['milk']"] -->|"add_item(cart)"| F["parameter basket"]
  F -.->|"same object"| O["['milk', 'egg']"]
  C -.->|"same object"| O
  style F fill:#3776ab,color:#fff
  style O fill:#b45309,color:#fff
A function parameter is a new label on the caller's existing object, not a fresh copy of it.
The function received the caller's actual list, so its append leaks out.
def add_item(basket):
    basket.append("egg")

cart = ["milk"]
add_item(cart)
print("cart is now:", cart)

== versus is

Two questions that look alike answer different things. == asks do these objects hold the same value? is asks are they literally the same object in memory?

[1, 2] == [1, 2] is True — the values match. [1, 2] is [1, 2] is False — they are two separate list objects that merely contain the same things. is checks identity; == checks equality, and for lists it compares element by element.

Reach for == almost always. Reach for is in two cases: comparing to the singleton None (if x is None), and when you genuinely care about identity rather than value.

Exercise

Write without_last(items) that returns a new list containing every item except the last. It must NOT mutate the original list you are given — copy first, then drop the final element.

def without_last(items):
    # return a copy with the last item dropped; leave `items` untouched
    pass
Exercise

Aliasing check. b = a does not copy the list. Predict both lines before you run it.

a = [1, 2]
b = a
b.append(3)
print(a)
print(a is b)
Exercise

This function should return a new list of words each with suffix added — but it mutates the caller's list as a side effect. Fix it so the caller's original words stay unchanged.

def add_suffix(words, suffix):
    for i in range(len(words)):
        words[i] = words[i] + suffix
    return words
Exercise

Given two separate lists with equal contents, what do [1, 2] == [1, 2] and [1, 2] is [1, 2] return?

Exercise

Write add_to_each(matrix, n) that returns a NEW matrix (a list of lists) in which every number has had n added to it. The caller's original matrix — including its INNER lists — must stay unchanged. A shallow matrix.copy() is not enough, because the inner lists are shared; you must rebuild them.

def add_to_each(matrix, n):
    # return a new matrix; do not mutate the original OR its inner lists
    pass

Recap

  • Immutable types (int, float, str, tuple) cannot change; operations that appear to change them create new objects. Mutable types (list, dict, set) change in place.
  • Assignment and function calls pass a reference to the same object — never an automatic copy. Two names can label one list.
  • == compares value; is compares identity. Use == for values, and is only for None and genuine identity checks.
  • A function that mutates its argument edits the caller's data. Copy first — list(x), x[:], or copy.deepcopy for nested data — when that is not the intent.

Next you will add type hints, the annotations that tell readers and tooling what type each value should be — a layer professional codebases lean on to catch exactly these reference bugs before the code ever runs.

Checkpoint quiz

Why does mutating a list inside a function affect the caller's list?

You want to check whether data was never set. Which test is correct?

Go deeper — technical resources