Skip to content

Python Memory Model

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 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 done

Each 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.

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 object

This 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:

Point 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 memory

a 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:

Equal values, different identities
a = [1, 2, 3]
b = [1, 2, 3] # a new, independent object
print(a == b) # True — same value
print(a is b) # False — different objects in memory

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 = 42
print(type(a)) # <class 'int'> ← it's an object, not a primitive
print(id(a)) # 140234567890 ← it has a memory address on the heap

So the same reference model applies:

a = 42
Stack Heap
───── ────
a ─────────► [ int: 42 ]
b = a
Stack Heap
───── ────
a ─────────► [ int: 42 ]
b ────────────┘ ← same object

However, 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 original
a = 42
b = a
print(a is b) # True ← same object
a += 1
print(a) # 43
print(b) # 42 ← b is unaffected
print(a is b) # False ← now different objects

This 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:

  1. Integer caching: CPython caches small integers from -5 to 256. Any variable assigned one of these values points to the same cached object:
a = 42
b = 42
print(a is b) # True ← guaranteed in CPython (-5 to 256)
a = 1000
b = 1000
print(a is b) # implementation dependent — do not rely on this
  1. String interning: CPython interns short strings that look like identifiers, so identical string literals often share the same object:
a = "hello" # looks like an identifier
b = "hello"
print(a is b) # True in CPython — but do not rely on it
a = "hello world" # does not look like an identifier
b = "hello world"
print(a is b) # not guaranteed — could be True or False

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 value
True # the boolean true
False # the boolean false

These 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 singleton
x = None
print(x is None) # ✅ correct — there is only one None
# is is correct here because True and False are singletons
print(x is True) # ✅ correct
print(x is False) # ✅ correct

Contrast 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 = 1000
b = 1000
print(a is b) # ⚠️ unreliable — do not use is for integers
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 guaranteed

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 lists

So modifying an inner list through the original affects the shallow copy too:

Inner list affected
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 anywhere

Now modifying the original has no effect on the deep copy:

Inner list no affected
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 independent
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]] ← modified
print(shallow) # [[1, 2, 99], [3, 4]] ← inner list affected
print(deep) # [[1, 2], [3, 4]] ← fully independent
OriginalShallow copyDeep copy
New outer container✅ Yes✅ Yes
New inner objects❌ No — shared✅ Yes — independent
Safe to mutate independently