Two lines of code. Same variable. Same operator. Completely different behavior.

a = [1, 2, 3]
b = a
b += [4]   # line A
print(a)   # [1, 2, 3, 4]

x = 1
y = x
y += 1     # line B
print(x)   # 1

Enter fullscreen mode Exit fullscreen mode

Line A changes a. Line B does not change x. The only difference is whether the variable holds a mutable or immutable object.

To understand why this happens, you need to understand how Python actually represents variables internally.


Variables Are Not Boxes

The box metaphor is how most introductory programming courses explain variables: a variable is a box that holds a value. You put 5 into the box called x. Later you can replace it with 10.

In Python this metaphor is accurate for immutable types and dangerously misleading for mutable types.

The more accurate model: a Python variable is a name that is bound to an object. The object exists independently of the name. Multiple names can be bound to the same object. Binding a name to a new object does not affect the old object or other names that reference it.

You can inspect this directly:

a = [1, 2, 3]
b = a
print(id(a) == id(b))  # True -- same object, two names

x = 5
y = x
print(id(x) == id(y))  # True -- both point to integer object 5

Enter fullscreen mode Exit fullscreen mode

Both cases start the same: two names pointing to the same object. What happens next depends on whether you mutate the object or rebind the name.


Two Fundamental Operations

Every operation on a Python object falls into one of two categories.

Mutation: the object at a given memory address is modified. All names pointing to that address see the change.

Rebinding: a name is pointed at a different memory address. Other names pointing to the original address are unaffected.

List methods like .append(), .extend(), .sort(), and item assignment lst[i] = x are mutations.

Assignment with = is rebinding.

The += operator is either mutation or rebinding depending on whether __iadd__ is implemented and the object is mutable.


Tracing Through the Object Graph

a = [[1, 2], [3, 4]]
b = a.copy()     # shallow copy
b[0].append(99)
print(a)
print(b)

Enter fullscreen mode Exit fullscreen mode

Output:

[[1, 2, 99], [3, 4]]
[[1, 2, 99], [3, 4]]

Enter fullscreen mode Exit fullscreen mode

a.copy() creates a new outer list. So a and b are different list objects. But the elements inside them are the same inner list objects. b[0] and a[0] both point to the same [1, 2] list.

Calling .append(99) on b[0] mutates that shared inner list. Both a[0] and b[0] reflect the change because they reference the same object.

Draw this as a diagram:

a ---------> [ref_to_inner_1, ref_to_inner_2]
b ---------> [ref_to_inner_1, ref_to_inner_2]
              |
              v
              inner_1: [1, 2]  <-- both a[0] and b[0] point here

Enter fullscreen mode Exit fullscreen mode

Appending 99 to inner_1 changes it to [1, 2, 99]. Both a[0] and b[0] see [1, 2, 99] because both still point to the same object.


How Function Arguments Fit This Model

def process(data):
    data = data + [99]   # rebinding -- creates new list
    return data

def process_mutating(data):
    data += [99]         # mutation -- extends existing list
    return data

original = [1, 2, 3]

result1 = process(original)
print(original)           # [1, 2, 3]

result2 = process_mutating(original)
print(original)           # [1, 2, 3, 99]

Enter fullscreen mode Exit fullscreen mode

When process is called, data is bound to the same object as original. data = data + [99] creates a new list and rebinds the local name data. The original object is untouched.

When process_mutating is called, data += [99] calls list.__iadd__([99]) which extends the existing object. The original object is mutated.


The Practical Debugging Rule

When you see unexpected mutation, ask: is there another name pointing to the same object?

When you see a change not being reflected somewhere, ask: was this a rebinding operation that only affected one name?

These two questions diagnose the vast majority of Python mutation bugs faster than any debugger.

Practice tracing mutation problems at pycodeit.com.