Mutability and References

Medium30 min read

Names, Objects, and References

Why Reference Semantics Matters

The Problem: Beginners spend hours debugging 'why did my list change when I assigned it?' — the answer is almost always reference semantics.

The Solution: Python passes references to objects, not values, so mutating an argument changes what the caller sees. Knowing what's mutable vs immutable, and copy vs deepcopy, eliminates this entire category of bug.

Real Impact: Functions like classes with __slots__ + frozen are how serious Python codebases avoid these traps at scale.

Real-World Analogy

Think of variables as labels on physical objects:

  • Variable = a sticky note with a name on it
  • Object = the physical thing the note is stuck to
  • Assignment = moving the note to a different object
  • Mutation = changing the object itself — every note pointing at it sees the change
  • deepcopy = photocopying the object so notes can be stuck on independent versions

In Python, variables are names bound to objects — they don't store values. Every object lives in memory, and any number of names can refer to the same object.

a = [1, 2, 3]
b = a               # b is another name for the same list
b.append(4)
print(a)            # [1, 2, 3, 4] — a sees the mutation!

print(id(a) == id(b))     # True — same object

Mutable vs Immutable

MutableImmutable
listtuple
dictstr
setfrozenset
bytearraybytes
most user classesint, float, complex, bool, None

Operations on immutable types return new objects. Operations on mutable types can modify in place.

s = "hello"
s += " world"       # s is rebound to a NEW string

lst = [1, 2]
lst.append(3)        # SAME list, modified in place
lst += [4]           # calls __iadd__ — mutates lst in place

Function Arguments Pass by Reference (sort of)

Python passes the reference to the argument's object — not the value, not a fresh reference. Mutating the argument inside the function mutates the caller's object too.

def add_item(items: list, item):
    items.append(item)     # mutates caller's list!

def rebind(items: list):
    items = [99]           # local rebind — caller unaffected

x = [1, 2]
add_item(x, 3)
print(x)            # [1, 2, 3]

rebind(x)
print(x)            # [1, 2, 3] — still

Defensive coding

If a function shouldn't mutate its argument, either copy it (items = list(items)), accept an iterable instead, or make the type immutable (tuple).

Identity vs Equality

a = [1, 2, 3]
b = [1, 2, 3]
c = a

a == b      # True — same value
a is b      # False — different objects
a is c      # True — same object

The Small-Int and String Interning Surprise

CPython caches small integers (-5 to 256) and short interned strings. Two literals may share an object — or may not. Never rely on this.

1 is 1                  # True (cached)
1000 is 1000            # False or True — implementation-defined

# Always use == for value comparison.
# Only use `is` for None, True, False, or for explicit identity checks.

copy and deepcopy

Need an independent copy? Choose between shallow and deep.

import copy

# Shallow copy — top-level container is new, but nested objects are shared
original = [[1, 2], [3, 4]]
shallow = original.copy()         # or copy.copy(original) or list(original) or original[:]
shallow[0].append(99)
print(original)               # [[1, 2, 99], [3, 4]] — original is mutated!

# Deep copy — recursively copies everything
deep = copy.deepcopy(original)
deep[0].append(99)
print(original)               # [[1, 2, 99], [3, 4]] — unchanged this time

Common Shallow-Copy Idioms

lst.copy()            # lists, dicts, sets all support .copy()
list(other)
dict(other)
other[:]              # slicing creates a copy for sequences
{**other}             # dict unpacking

Common Mutability Pitfalls

⚠️ Watch Out

  • Mutable default arguments: Default values are evaluated once at definition. def f(x=[]) shares one list across all calls. Use x=None.
  • Class-level mutable attributes: Same risk — all instances share the attribute until one rebinds it. Initialize mutables in __init__.
  • [x]*n with mutable x: Creates n references to the same object. Use a comprehension instead.
  • Iterating while mutating: Modifying a list/dict/set during iteration can silently skip items. Build a copy first or accumulate changes.
  • Tuple of mutables looks immutable: t = (1, [2, 3]) can have t[1] mutated. t is still hashable but unsafe as a dict key.
# BAD
class Container:
    items = []                  # shared across all instances!

# GOOD
class Container:
    def __init__(self):
        self.items = []         # per-instance

🎯 Practice Exercises

Exercise 1: Spot the bug

Write a function that takes a list of users, sorts it, and returns the top 3 — without modifying the input. Then write a version that intentionally mutates and compare.

Exercise 2: Default-argument gotcha

Write a buggy function with a mutable default. Demonstrate the bug. Fix it using None.

Exercise 3: Deep vs shallow

Build a nested list of lists. Demonstrate when shallow copy is enough and when you need deep copy.

Exercise 4: Immutable Record

Build a value class using @dataclass(frozen=True, slots=True) and verify mutations raise FrozenInstanceError.