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 = bmakesarefer to the same object asb. 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 | from __future__ import annotations |
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 | def push(x: list[int]) -> None: |
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 | from dataclasses import dataclass |
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 | a = [1, 2] |
Patterns:
- We use
isforNone: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 | b: list[int] = a.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 | a: list[int] = [1, 2, 3] |
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
bdoes not affect the corresponding slot ina, since they are different outer lists.
Case 2: nested list (e.g. list[list[int]])
1 | m1: list[list[int]] = [[0, 0], [0, 0]] |
Here changes do leak:
- The outer lists
m1andm2are different. - But each element is itself a list, which is mutable.
- Because the copy is shallow, both
m1[0]andm2[0]refer to the same inner list object. - Mutating
m2[0][0]mutates that shared inner list, som1sees it.
If we instead change only the outer structure:
1 | m2.append([1, 1]) # add a new inner list to m2 only |
Appending affects the outer list object (which is not shared), so m1 remains unchanged.
Rules of thumb for copying
.copy(),list(a), anda[:]all perform a shallow copy.Shallow copy is usually fine for:
- Flat lists of immutable values (
int,str,tuple, etc.).
- Flat lists of immutable values (
For nested or mutable contents:
A shallow copy shares inner objects.
To obtain a fully independent 2D list, we can write:
1
2m1: 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
8import 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.
Common reference-related pitfalls
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 | def bad(acc: list[int] = []): # shared list across calls |
Correct pattern:
1 | def good(acc: list[int] | None = None) -> list[int]: |
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 | grid = [[0] * 3] * 3 # three references to the SAME inner list |
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;Nonechecks useis. .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.