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
| Mutable | Immutable |
|---|---|
| list | tuple |
| dict | str |
| set | frozenset |
| bytearray | bytes |
| most user classes | int, 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. Usex=None. - Class-level mutable attributes: Same risk — all instances share the attribute until one rebinds it. Initialize mutables in
__init__. [x]*nwith mutable x: Createsnreferences 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 havet[1]mutated.tis 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.