Python Memory Model
How Python Stores Data
Section titled “How Python Stores Data”Understanding how Python manages memory is essential to avoiding subtle bugs, especially in functional programming, where the distinction between transforming data and mutating data is critical.
Python’s memory model has two regions:
- The stack: holds variable names and their references. It is fast, local or private to each function call, and automatically cleaned up when the function returns.
- The heap: holds the actual objects. Every value you create in Python lives here, managed by Python’s garbage collector.
The critical insight is that variables are not boxes that contain values, they are labels that point to objects on the heap.
When you write a = 42, Python creates an integer object 42 on the heap and makes a a reference to it. The variable holds an
address, not the value itself.
┌─────────────────────────────────────────┐│ Python Memory Model ││ ││ ┌─────────────┐ ┌─────────────────┐ ││ │ Stack │ │ Heap │ ││ │ │ │ │ ││ │ name → ref──┼───►│ Object [42] │ ││ │ name → ref──┼───►│ Object ["hi"] │ ││ │ │ │ Object [list] │ ││ └─────────────┘ └─────────────────┘ ││ ││ Variables hold REFERENCES, not values │└─────────────────────────────────────────┘The Stack
Section titled “The Stack”The stack is fast because it uses a simple push/pop mechanism, when a function is called, a new block of memory (called a stack frame) is pushed onto the stack to hold that function’s local variables. When the function returns, the entire frame is popped off and discarded instantly. No garbage collection needed, no searching, just move a pointer.
It is local to each function call because each call gets its own independent frame. If you call the same function twice, each call has its own separate variables that cannot see or affect each other:
def greet(name): def add(a, b): msg = f"Hi {name}" result = a + b return msg return result
greet("Alice") greet("Bob") ┌──────────────┐ ┌──────────────┐ │ Frame: greet │ │ Frame: greet │ │──────────────│ │──────────────│ │ name → Alice │ │ name → Bob │ │ msg → "Hi.."│ │ msg → "Hi.."│ └──────────────┘ └──────────────┘ ↑ popped ↑ popped when done when done
add(1, 2) add(10, 20) ┌──────────────┐ ┌──────────────┐ │ Frame: add │ │ Frame: add │ │──────────────│ │──────────────│ │ a → 1 │ │ a → 10 │ │ b → 2 │ │ b → 20 │ │ result → 3 │ │ result → 30 │ └──────────────┘ └──────────────┘ ↑ popped ↑ popped when done when doneEach frame is completely isolated, name in one call to greet has nothing to do with name in another. When the function
returns, the frame disappears and all its local variables go with it. This is why local variables do not persist between calls
and why two calls to the same function never interfere with each other.
References, not Copies
Section titled “References, not Copies”When you assign one variable to another, Python does not create a new object, it simply makes both variables point to the same object on the heap. Think of it as giving the same object a second label, not photocopying it.
a = [1, 2, 3]
Stack Heap ───── ──── a ─────────► [ 1, 2, 3 ]
b = a ← no new object created, just a second label
Stack Heap ───── ──── a ─────────► [ 1, 2, 3 ] ▲ b ────────────┘ ← both point to the SAME object
b.append(4) ← modifies the object both a and b point to
Stack Heap ───── ──── a ─────────► [ 1, 2, 3, 4 ] ▲ b ────────────┘ ← a is affected because it is the same objectThis is the source of one of the most common Python surprises, modifying a list through one variable silently affects all other variables pointing to the same object:
a = [1, 2, 3]b = a # b points to the SAME object, not a copy
b.append(4)print(a) # [1, 2, 3, 4] ← a is affected!print(a is b) # True — same object in memorya is b returns True because is checks identity, whether two variables point to the exact same object in
memory, not equality of value. Two different objects can have equal values but different identities:
a = [1, 2, 3]b = [1, 2, 3] # a new, independent object
print(a == b) # True — same valueprint(a is b) # False — different objects in memoryNo primitive values in the C/Java sense
Section titled “No primitive values in the C/Java sense”In Python everything is an object on the heap, including integers, floats, booleans, and strings. There are no primitive
values in the C/Java sense. Even 42 is a fully fledged object with a type, an identity, and a reference count.
a = 42print(type(a)) # <class 'int'> ← it's an object, not a primitiveprint(id(a)) # 140234567890 ← it has a memory address on the heapSo the same reference model applies:
a = 42
Stack Heap ───── ──── a ─────────► [ int: 42 ]
b = a
Stack Heap ───── ──── a ─────────► [ int: 42 ] ▲ b ────────────┘ ← same objectHowever, you don’t see the same surprising mutation behavior as with lists because integers are immutable, you cannot change the value of an integer object in place.
When you do a += 1, Python does not modify the existing 42 object, it creates a brand new 43 object and makes a point
to it:
a = 42 b = a ← both point to the same 42
Stack Heap ───── ──── a ─────────► [ int: 42 ] ▲ b ────────────┘
a += 1 ← a new object is created, a is repointed
Stack Heap ───── ──── a ─────────► [ int: 43 ] ← new object
b ─────────► [ int: 42 ] ← b still points to the originala = 42b = a
print(a is b) # True ← same object
a += 1
print(a) # 43print(b) # 42 ← b is unaffectedprint(a is b) # False ← now different objectsThis is the key distinction:
| Mutable (list, dict, set) | Immutable (int, str, tuple) | |
|---|---|---|
| Same reference model | ✅ Yes | ✅ Yes |
| Modification affects all references | ✅ Yes | ❌ No — new object created |
| Surprise mutation risk | ✅ High | ❌ None |
So the reference model is universal in Python, but immutability is what makes integers and strings safe to share freely without worrying about one variable silently affecting another.
A Word of Warning — is and Object Identity
Section titled “A Word of Warning — is and Object Identity”Python’s CPython implementation applies two optimisations worth knowing about, but neither is guaranteed by the language specification:
- Integer caching: CPython caches small integers from -5 to 256. Any variable assigned one of these values points to the same cached object:
a = 42b = 42print(a is b) # True ← guaranteed in CPython (-5 to 256)
a = 1000b = 1000print(a is b) # implementation dependent — do not rely on this- String interning: CPython interns short strings that look like identifiers, so identical string literals often share the same object:
a = "hello" # looks like an identifierb = "hello"print(a is b) # True in CPython — but do not rely on it
a = "hello world" # does not look like an identifierb = "hello world"print(a is b) # not guaranteed — could be True or FalseSingleton
Section titled “Singleton”A singleton is an object that exists only once in memory, there is guaranteed to be exactly one instance
of it, ever. When you compare with is, you are checking identity (same object in memory), so it only makes sense to use
is when you are certain the object is a singleton because then identity and value are the same thing.
Python has exactly three built-in singletons that are guaranteed by the language specification:
None # represents the absence of a valueTrue # the boolean trueFalse # the boolean falseThese are created once when Python starts and reused forever. There is only ever one None object, one True object, and
one False object in the entire program:
# `is` is correct here because None is guaranteed to be a singletonx = Noneprint(x is None) # ✅ correct — there is only one None
# is is correct here because True and False are singletonsprint(x is True) # ✅ correctprint(x is False) # ✅ correctContrast this with integers and strings, they are not singletons (except for the CPython caching optimisation we discussed).
Two separate "hello" strings could theoretically be two different objects in memory, so is is unreliable:
a = "hello"b = "hello"print(a is b) # ⚠️ unreliable — do not use is for strings
a = 1000b = 1000print(a is b) # ⚠️ unreliable — do not use is for integersA simple mental model
Section titled “A simple mental model” None True / False ──── ────────────
Heap Heap ──── ──── [ None ] ← only one [ True ] ← only one [ False ] ← only one
x = None y = True z = None w = True
x ──────► [ None ] y ──────► [ True ] z ──────► [ None ] w ──────► [ True ] ▲ ▲ └── same object └── same object guaranteed guaranteedMaking Copies
Section titled “Making Copies”When you need a truly independent copy of an object, Python offers two levels of copying. The difference between them only becomes visible when your data is nested, a list that contains other lists, for example.
Shallow Copy — New Container, Shared Contents
Section titled “Shallow Copy — New Container, Shared Contents”A shallow copy creates a new outer object but does not copy the inner objects, they are still shared between the original and the copy:
original = [[1, 2], [3, 4]] ← inner list shallow = original.copy()
Stack Heap ───── ────
original ──────► [ list ]──────► [ list: 1, 2 ] │ ▲ shallow ──────► [ list ]────────────┘ │ └──────────────► [ list: 3, 4 ] ▲ │ both point to the same inner listsSo modifying an inner list through the original affects the shallow copy too:
original = [[1, 2], [3, 4]]shallow = original.copy()
original[0].append(99)
print(original) # [[1, 2, 99], [3, 4]]print(shallow) # [[1, 2, 99], [3, 4]] ← inner list affected!Deep Copy — Fully Independent at Every Level
Section titled “Deep Copy — Fully Independent at Every Level”A deep copy walks the entire structure recursively and creates independent copies of every object it finds, outer and inner:
original = [[1, 2], [3, 4]] ← inner list deep = copy.deepcopy(original)
Stack Heap ───── ────
original ──────► [ list ]──────► [ list: 1, 2 ] ← original inner lists [ list: 3, 4 ]
deep ──────► [ list ]──────► [ list: 1, 2 ] ← new independent copies [ list: 3, 4 ]
no shared references anywhereNow modifying the original has no effect on the deep copy:
import copy
original = [[1, 2], [3, 4]]deep = copy.deepcopy(original)
original[0].append(99)
print(original) # [[1, 2, 99], [3, 4]]print(deep) # [[1, 2], [3, 4]] ← fully independentSide by Side
Section titled “Side by Side”import copy
original = [[1, 2], [3, 4]]
shallow = original.copy()deep = copy.deepcopy(original)
original[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] ← modifiedprint(shallow) # [[1, 2, 99], [3, 4]] ← inner list affectedprint(deep) # [[1, 2], [3, 4]] ← fully independent| Original | Shallow copy | Deep copy | |
|---|---|---|---|
| New outer container | — | ✅ Yes | ✅ Yes |
| New inner objects | — | ❌ No — shared | ✅ Yes — independent |
| Safe to mutate independently | ❌ | ❌ | ✅ |