2. Names, objects, and references: the model behind every Python bug
This chapter is the core of the language. Read it slowly.
2.1 Everything is an object; variables are names
In C, int x = 5; creates a 4-byte box called x that holds 5. In Python,
x = 5 does two separate things:
- It gets an object: a heap structure with a type (
int), a value (5), and a reference count. - It binds the name
xto that object, in the current namespace (a dict from names to objects).
namespace (dict) heap
┌──────────┐
│ "x" ────┼────────▶ ┌────────────────────┐
└──────────┘ │ PyLongObject │
│ refcount: 1 (+...) │
│ type: int │
│ value: 5 │
└────────────────────┘
Every value is an object: integers, functions, classes, modules, None.
Every object has an identity (id(obj), its address in CPython), a
type (type(obj)), and a value.
2.2 Assignment never copies
a = [1, 2, 3]
b = a # bind a second name to the SAME list object
b.append(4)
print(a) # [1, 2, 3, 4]
print(a is b) # True: same object
print(id(a) == id(b)) # True
"a" ──┐
├──▶ list [1, 2, 3, 4]
"b" ──┘
This is the single most important rule in Python: = binds a name to an
object. It never copies the object. Passing an argument to a function is
also assignment (to the parameter name), so functions receive the same
object, not a copy:
def add_admin(users: list[str]) -> None:
users.append("admin") # mutates the caller's list
team = ["ada", "linus"]
add_admin(team)
print(team) # ['ada', 'linus', 'admin']
People argue whether this is "pass by reference" or "pass by value". The accurate name is pass by object reference (or "call by sharing"): the function gets a new name for the same object. It can mutate the object, but rebinding its parameter does not affect the caller:
def reset(users: list[str]) -> None:
users = [] # rebinds the local name only
team = ["ada"]
reset(team)
print(team) # ['ada']: unchanged
Across the series: Go copies a slice header on assignment, so two slices can share a backing array and surprise you (Go §6). Rust forbids two mutable paths to the same data at compile time (Rust §7). Python allows unlimited sharing, checks nothing, and relies on you knowing which objects are mutable.
2.3 Mutable and immutable types
| Immutable (cannot change after creation) | Mutable (can change in place) |
|---|---|
int, float, complex, bool |
list |
str, bytes |
dict |
tuple, frozenset |
set |
None |
bytearray |
| most user-defined class instances |
"Changing" an immutable object really creates a new object and rebinds the name:
s = "hello"
t = s
s += " world" # creates a NEW str; rebinds s
print(t) # hello: t still names the old object
n = 10
before = id(n)
n += 1 # new int object
print(id(n) == before) # False
With a mutable object, += mutates in place:
xs = [1]
ys = xs
xs += [2] # list.__iadd__ extends in place
print(ys) # [1, 2]
So x += 1 means "mutate" for lists and "rebind" for ints and strings. If
that seems inconsistent, remember the rule: the operation is defined by the
object's type, not by the syntax.
2.4 is versus ==
==compares values (calls__eq__).iscompares identity (same object?).
a = [1, 2]
b = [1, 2]
print(a == b, a is b) # True False
Use is only for singletons: x is None, x is True in rare cases, and
sentinel objects you created. Never use is to compare numbers or strings.
CPython caches small integers (−5 to 256) and some strings, so is appears
to work in tests and then fails in production:
>>> a = 256; b = 256; a is b
True
>>> a = int("1000"); b = int("1000"); a is b
False
2.5 Shallow copy and deep copy
When you do want a copy, ask for one:
import copy
matrix = [[0, 0], [0, 0]]
shallow = matrix.copy() # or list(matrix), or matrix[:]
deep = copy.deepcopy(matrix)
matrix[0][0] = 99
print(shallow[0][0]) # 99: shallow copy shares the inner lists
print(deep[0][0]) # 0
matrix ──▶ [ ● , ● ] shallow ──▶ [ ● , ● ]
│ │ │ │
▼ ▼ │ │
[99,0] [0,0] ◀────────────────────┘───┘ (same inner lists)
deep ──▶ [ ● , ● ] ──▶ [0,0] [0,0] (new inner lists)
The classic grid bug uses the same mechanism:
# WRONG: three references to ONE inner list
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
# Right: a new inner list per row
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
2.6 The mutable default argument
Default values are evaluated once, when the def statement runs, not
on each call. A mutable default is shared across all calls:
# WRONG
def add_tag(tag: str, tags: list[str] = []) -> list[str]:
tags.append(tag)
return tags
print(add_tag("a")) # ['a']
print(add_tag("b")) # ['a', 'b'] <- the same list, kept between calls
# Right: use None as the sentinel and create the list inside
def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
if tags is None:
tags = []
tags.append(tag)
return tags
ruff check flags this pattern (rule B006). Turn that rule on.
2.7 War story
A feature-engineering function in a training pipeline took a config dict,
"normalized" it in place, and returned it. The caller passed the same
default config object to every experiment in a hyperparameter sweep. Each
run mutated the shared dict, so experiment 7 silently trained with
settings left over from experiment 6. The results were not reproducible and
the team spent a week blaming random seeds. The fix was one line:
cfg = copy.deepcopy(cfg) at the top of the function, and later a frozen
dataclass (Ch 15) so the mistake could not compile past the type checker.
Lesson: in Python, any function that receives a mutable object can change
it for everyone. Make "does this mutate its argument?" part of every
function's contract, and prefer immutable data for configuration.