Python variables do not store raw values directly but instead hold references to objects on the heap. This note builds a practical mental model for how references work, how they compare to C++ pointers, and how this affects assignment, mutation, function parameters, copying, and common pitfalls. We include small typed examples and a focused discussion of shallow copies such as a.copy() vs m1.copy() in both flat and nested list cases.

Core mental model: variables as references

In Python:

  • A variable name does not contain a value; it is bound to an object.
  • Assignment copies the reference, not the object.
  • Many different names can refer to the same object (aliasing).
  • Python’s garbage collector reclaims objects when no references remain; there is no manual delete.

A helpful intuition is: a Python variable behaves like a safe pointer to an object, without pointer arithmetic.

Python references vs C++ pointers

Although Python references and C++ pointers both “point to” something, the languages impose different capabilities and responsibilities.

Address access

  • C++ We can take addresses (&x), store them in pointers, and perform pointer arithmetic.
  • Python We cannot do pointer arithmetic or directly manipulate addresses. id(obj) returns an identity for the object (often the address in CPython) but is only intended for identity checks and debugging.

Object lifetime

  • C++ Lifetime is usually explicit or managed via RAII (new/delete, stack objects, smart pointers).
  • Python Lifetime is automatic: reference counting plus garbage collection. When no references remain, the object becomes eligible for reclamation.

Assignment and aliasing

  • C++ p = q; copies the pointer value; both point to the same object.
  • Python a = b makes a refer to the same object as b. No data inside the object is copied by simple assignment.

Mutation through aliases

In both languages, if two names refer to the same underlying object, mutating that object via one name is visible via the other.

Understanding this is essential for reasoning about containers, function calls, and data structures in Python.

Small worked examples (with type hints)

Aliasing: two names, one list

1
2
3
4
5
6
7
8
9
10
from __future__ import annotations


def aliasing_demo() -> None:
"""Two variables referencing the same list object."""
a: list[int] = [1, 2, 3]
b: list[int] = a # copy the *reference*, not the list
b.append(4)
assert a == [1, 2, 3, 4] # a sees the change
assert a is b # same identity (same object)

Here a and b are two names for the same list object.

Function parameters: call-by-sharing (pass-by-object-reference)

Python uses call-by-sharing:

  • The function receives a new local name that refers to the same object as the caller.
  • Mutating the object is visible to the caller.
  • Rebinding the local parameter name is not visible to the caller.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def push(x: list[int]) -> None:
"""Mutates the same list object the caller passed."""
x.append(99)


def rebind(x: list[int]) -> None:
"""Rebinds local x; the caller's variable is unaffected."""
x = [42]


def param_demo() -> None:
a: list[int] = []
push(a)
assert a == [99] # mutation is visible

rebind(a)
assert a == [99] # local rebinding had no effect on a

We mutate the object to affect the caller, and rebind the name to stay local.

Linked lists: next as a reference

In linked-list problems, each node’s next field simply holds a reference to another node.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from dataclasses import dataclass

@dataclass
class ListNode:
val: int
next: ListNode | None = None


def link_demo() -> None:
n1 = ListNode(2)
n2 = ListNode(4)
n1.next = n2 # store a reference to n2 inside n1

p = n1
p = p.next # follow the reference; p now refers to n2
assert p is n2
assert p.val == 4

There is no special language feature here: next is just an attribute whose value happens to be a reference to another ListNode.

Identity vs equality

Python distinguishes:

  • Identity: “Is this the same object?” Use is / is not.
  • Equality: “Do these objects have the same value?” Use == / !=.
1
2
3
4
5
6
7
a = [1, 2]
b = [1, 2]
c = a

assert a == b # same content
assert a is not b # different objects
assert a is c # c is another reference to the same list

Patterns:

  • We use is for None: if x is None: ....
  • We use == to compare values.

id(obj) returns an identity token that is unique while the object lives; for practical purposes it can be treated as “the thing we would compare to check object identity.”

Shallow copies: a.copy() vs m1.copy()

Now we incorporate a subtle but very important point: why do these two lines feel different in practice?

1
2
b: list[int] = a.copy()
m2: list[list[int]] = m1.copy()

They behave differently not because the syntax changes, but because of what the lists contain.

Both .copy() calls perform a shallow copy of the outer list:

  • Create a new outer list.
  • Copy the references to elements into that new list.
  • The elements themselves (e.g. integers, inner lists) are not duplicated.

Whether changes “leak” between original and copy depends on mutability and structure of the elements.

Case 1: flat list of immutables (e.g. list[int])

1
2
3
4
5
6
7
8
a: list[int] = [1, 2, 3]
b: list[int] = a.copy() # shallow copy (new outer list)

b.append(4) # mutate b's outer list
b[0] = 99 # rebind b's slot to a new int

# a == [1, 2, 3]
# b == [99, 2, 3, 4]

No changes leak back to a because:

  • The outer lists are different objects.
  • Integers are immutable. We cannot mutate “the int 1”; we can only rebind a slot to a different integer.
  • Rebinding a slot in b does not affect the corresponding slot in a, since they are different outer lists.

Case 2: nested list (e.g. list[list[int]])

1
2
3
4
5
6
7
m1: list[list[int]] = [[0, 0], [0, 0]]
m2: list[list[int]] = m1.copy() # shallow copy

m2[0][0] = 9 # mutate the inner list object

# m1 == [[9, 0], [0, 0]]
# m2 == [[9, 0], [0, 0]]

Here changes do leak:

  • The outer lists m1 and m2 are different.
  • But each element is itself a list, which is mutable.
  • Because the copy is shallow, both m1[0] and m2[0] refer to the same inner list object.
  • Mutating m2[0][0] mutates that shared inner list, so m1 sees it.

If we instead change only the outer structure:

1
2
3
4
m2.append([1, 1])                 # add a new inner list to m2 only

# m1 == [[9, 0], [0, 0]]
# m2 == [[9, 0], [0, 0], [1, 1]]

Appending affects the outer list object (which is not shared), so m1 remains unchanged.

Rules of thumb for copying

  • .copy(), list(a), and a[:] all perform a shallow copy.

  • Shallow copy is usually fine for:

    • Flat lists of immutable values (int, str, tuple, etc.).
  • For nested or mutable contents:

    • A shallow copy shares inner objects.

    • To obtain a fully independent 2D list, we can write:

      1
      2
      m1: list[list[int]] = [[0, 0], [0, 0]]
      m2: list[list[int]] = [row.copy() for row in m1]
    • For arbitrary nested structures, we can use copy.deepcopy:

      1
      2
      3
      4
      5
      6
      7
      8
      import copy

      m1 = [[0, 0], [0, 0]]
      m3 = copy.deepcopy(m1) # full independent copy
      m3[0][0] = 9

      # m1 == [[0, 0], [0, 0]]
      # m3 == [[9, 0], [0, 0]]

The apparent “different results” between b = a.copy() and m2 = m1.copy() come from the shape and mutability of the data inside the lists, not from the .copy() method itself: both calls perform shallow copies by design.

Mutable default arguments

Default argument values are evaluated once, when the function is defined, and then reused. Using a mutable default such as [] creates a single shared object.

1
2
3
def bad(acc: list[int] = []):        # shared list across calls
acc.append(1)
return acc

Correct pattern:

1
2
3
4
5
def good(acc: list[int] | None = None) -> list[int]:
if acc is None:
acc = []
acc.append(1)
return acc

Here we use None as a sentinel and allocate a fresh list when needed.

List multiplication with inner lists

Using * with a list duplicates references, not inner objects.

1
2
3
4
5
6
7
grid = [[0] * 3] * 3      # three references to the SAME inner list
grid[0][0] = 9

# All rows share the change:
# grid == [[9, 0, 0],
# [9, 0, 0],
# [9, 0, 0]]

Correct pattern for a proper 2D grid:

1
grid2 = [[0] * 3 for _ in range(3)]  # distinct inner lists

Each iteration in the comprehension creates a new inner list, so rows are independent.

Summary

  • Python variables hold references to objects on the heap, not raw values.
  • Assignment copies references, so aliasing is common and often intentional.
  • Python uses call-by-sharing for function calls: parameters refer to the same objects as the caller’s variables.
  • Identity (is) and equality (==) are distinct; None checks use is.
  • .copy() performs a shallow copy: the outer container is new, but element references are reused.
    • This is safe for flat lists of immutables.
    • For nested or mutable contents, shallow copies share inner objects, which can lead to surprising interactions.
  • Typical bugs arise from unintentionally shared references: mutable defaults, list multiplication, and shallow copies of nested structures.

With this mental model, we can reason more systematically about lists, dictionaries, linked lists, trees, and other data structures in Python, especially in algorithmic and LeetCode-style problems.