Python from First PrinciplesPost 2 of 20 · 20 of 20 written

2. Names, objects and references

Python · 2 of 204 min readOct 2026

Assignment in Python never copies data; it binds a name to an object. This single rule explains most of Python’s “surprising” behavior.

The model

Every object has three properties:

  • Identity: which object it is, shown by id(x). It never changes.
  • Type: what it can do, shown by type(x). In practice it never changes either.
  • Value: its contents. These may change, but only if the type is mutable.

A name (a variable) is a label in a namespace pointing at an object. Names have no type; objects do.

a = [1, 2, 3]
b = a            # no copy: b is a second label on the same list
b.append(4)
print(a)         # [1, 2, 3, 4]
print(a is b)    # True
c = list(a)      # a real (shallow) copy: a new list object
print(c is a)    # False

Whenever a snippet confuses you, draw names on the left, objects on the right, and arrows between them. That habit resolves most Python puzzles.

[embedded content: names and objects after b = a and c = list(a)]

Each arrow is a reference. a and b reach the same list, so a change made through either name shows through both; c reaches a separate list that merely has an equal value.

Rebinding versus mutating

These two look alike and behave completely differently:

a = [1, 2]
b = a
b = b + [3]      # builds a NEW list and rebinds b; a is unchanged

b = a
b += [3]         # list.__iadd__ mutates in place; a changes too!

x = (1, 2)
y = x
y += (3,)        # tuples are immutable, so += must rebind; x is still (1, 2)

The rule: x = ... always rebinds the name. x.method(...), x[i] = ... and x.attr = ... mutate the object that x points to.

Mutable and immutable types

  • Immutable: int, float, bool, str, bytes, tuple, frozenset, None.
  • Mutable: list, dict, set, bytearray, most of your own classes, NumPy arrays, PyTorch tensors.

Immutability belongs to the object, not the name. A tuple of lists cannot gain or lose elements, but the lists inside it can still change. Only hashable objects can be dict keys or set members (Part 3).

is versus ==

  • a == b calls a.__eq__(b) and asks: are the values equal?
  • a is b compares identity and asks: is this the same object?

Use is only for singletons such as None, True, False and sentinel objects: write x is None, not x == None. CPython caches small integers (−5 to 256) and some strings, so is sometimes “works” on them by accident. Never rely on that.

Array twist: in NumPy and PyTorch, arr == 0 returns an array of booleans, not one bool. That is why if arr == 0: raises “the truth value of an array is ambiguous”. Write (arr == 0).all() or .any().

Shallow and deep copies

import copy

grid = [[0, 0], [0, 0]]
shallow = list(grid)          # same as grid.copy() or grid[:]
deep = copy.deepcopy(grid)
grid[0][0] = 9
shallow[0][0]                 # 9: the inner lists are shared
deep[0][0]                    # 0: everything was duplicated

bad = [[0] * 3] * 3           # three references to ONE inner list
good = [[0] * 3 for _ in range(3)]

AI context: teacher = student creates no second model; both names train the same weights. copy.deepcopy(model) creates a real copy (for EMA or teacher models), and doubles the parameter memory.

Passing arguments: call by sharing

A function receives a new name bound to the same object the caller passed. Mutating that object is visible to the caller; rebinding the parameter is not.

def add_item(lst):
    lst.append(1)      # the caller sees this

def replace(lst):
    lst = [99]         # local rebinding; the caller sees nothing

This is neither “pass by value” nor “pass by reference” in the C++ sense. It is passing the arrow.

The mutable default argument trap

Default values are evaluated once, when def runs, and stored on the function object. Every call then shares that one object.

def chat(prompt, history=[]):      # one list shared by every call
    history.append(prompt)
    return history

chat("hi"); chat("again")          # ['hi', 'again']: history leaks between calls

def chat(prompt, history=None):    # the fix
    if history is None:
        history = []
    history.append(prompt)
    return history

In LLM code this bug leaks conversation history between unrelated requests.

Memory: reference counting plus a cycle collector

  • CPython counts the references to every object. When the count reaches zero, the object is freed immediately.
  • del x removes a name, not an object. The object dies only when nothing references it any more.
  • Reference cycles (a points to b, b points to a) are freed by a periodic garbage collector (gc.collect()).

AI context: GPU memory is released when a tensor’s last reference disappears. Appending loss tensors to a list (instead of loss.item()) keeps every step’s computation graph alive and looks like a memory leak. torch.cuda.empty_cache() cannot free tensors you still reference.

Questions to ask:

  • Who else holds a reference to this object?
  • Does this operation mutate in place or create a new object?
  • Is this a copy, or the same object under a second name?