Frontend Labs
⋯

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:

  1. It gets an object: a heap structure with a type (int), a value (5), and a reference count.
  2. It binds the name x to 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__).
  • is compares 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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36