Python Immutability and Memory Management
The Immutability Paradox
Question: If integers are immutable in Python, won’t counters that keep incrementing create thousands of new objects and dump memory?
Short Answer: No! Python’s sophisticated memory management prevents this problem.
How Python Manages Immutable Objects Efficiently
1. Reference Counting & Immediate Garbage Collection
When you do counter += 1, the old integer object loses its reference immediately and is freed from memory.
counter = 0
for i in range(1000000):
counter += 1 # Old object freed immediately, only ONE object exists at a time
At any moment, only the current integer object exists in memory, not all million intermediate values.
2. Small Integer Caching (-5 to 256)
Python pre-creates and caches integers from -5 to 256. These objects are never destroyed and are reused:
a = 100
b = 100
print(a is b) # True - same object in memory!
print(id(a) == id(b)) # True
x = 1000
y = 1000
print(x is y) # False - different objects (but still efficiently managed)
Result: Most counter operations (0-256) reuse the same pre-existing objects - zero memory overhead!
3. Integer Pool for Large Numbers
For integers > 256, Python maintains an internal free list to quickly reuse memory slots without allocating from scratch.
Why Make These Types Immutable?
If immutability requires creating new objects, why not make them mutable to avoid the overhead?
Reason 1: Hashability - Dictionary Keys & Sets
Immutable objects can be hashed and used as dictionary/set keys:
# Works because tuples and strings are immutable
my_dict = {(1, 2): "coordinates", "name": "value"}
my_set = {1, 2, 3, "hello"}
# If integers were mutable, this would be chaos:
x = 5
my_dict[x] = "five"
# Imagine if x could be changed to 6...
# Where would our data go? Hash tables would break!
Reason 2: No Surprising Side Effects
def add_tax(price):
tax_rate = 0.1
total = price + (price * tax_rate)
return total
original_price = 100
final_price = add_tax(original_price)
print(original_price) # Still 100! Function didn't modify it
If integers were mutable, passing them to functions would be unpredictable - you’d never know if they’d be changed.
Reason 3: Thread Safety
Immutable objects don’t need locks in concurrent programs - they can’t be modified, so no race conditions.
# Safe to share across threads
shared_config = (100, "production", True)
# No need for locks - it can't change!
Reason 4: Optimization Opportunities
Immutability enables optimizations like:
- Integer caching
- String interning
- Safe object reuse
- Compiler optimizations
These wouldn’t be possible with mutable objects!
Python’s Calling Convention: “Call by Object Reference”
Critical Concept: Python does NOT use “call by value” (no copying) or pure “call by reference” (can’t rebind caller’s variables).
How It Actually Works
Python passes references to objects, but assignment creates new bindings:
def modify_number(x):
print(f"Inside function, id: {id(x)}")
x = x + 10 # Creates NEW object, rebinds local variable 'x'
print(f"After modification, id: {id(x)}") # Different id!
return x
num = 100
print(f"Original id: {id(num)}")
result = modify_number(num)
print(f"After function, original id: {id(num)}") # Same id as before
print(f"Original value: {num}") # Still 100!
Output:
Original id: 140234567891234
Inside function, id: 140234567891234 ← Same object reference passed!
After modification, id: 140234567891456 ← New object created
After function, original id: 140234567891234 ← Original unchanged
Original value: 100
Key Insights:
- No copying happens - The reference is passed (efficient!)
- Immutable objects can’t be changed - So
x = x + 10creates a NEW integer - Assignment rebinds the local variable to the new object
- Original remains untouched because it’s immutable
Contrast with Mutable Objects
def modify_list(my_list):
print(f"List id inside: {id(my_list)}")
my_list.append(4) # Modifies the SAME object in place
print(f"List id after append: {id(my_list)}") # Same id!
original_list = [1, 2, 3]
print(f"Original id: {id(original_list)}")
modify_list(original_list)
print(f"After function: {original_list}") # [1, 2, 3, 4] - Changed!
print(f"Still same id: {id(original_list)}") # Same object!
Output:
Original id: 140234567891000
List id inside: 140234567891000 ← Same reference
List id after append: 140234567891000 ← Still same object
After function: [1, 2, 3, 4] ← Original modified!
Still same id: 140234567891000
The Bottom Line
| Aspect | Reality |
|---|---|
| Memory Overhead | Minimal - reference counting, caching, and reuse prevent accumulation |
| Performance | Excellent - Python’s optimizations make it negligible |
| Copying | Never happens - references are passed |
| Safety | High - immutability prevents bugs, enables thread safety |
| Design Trade-off | Prioritizes correctness over tiny theoretical overhead |
Python’s Philosophy: Immutability provides safety, predictability, and enables powerful optimizations. The memory management is so efficient that the benefits far outweigh any costs.
Quick Reference: Mutable vs Immutable
Immutable (Cannot Change)
int,float,boolstr,bytestuplefrozenset
Mutable (Can Change)
listdictset- Custom objects (by default)
Test Mutability
# Immutable - creates new object
x = 5
old_id = id(x)
x += 1
print(id(x) == old_id) # False
# Mutable - modifies same object
my_list = [1, 2, 3]
old_id = id(my_list)
my_list.append(4)
print(id(my_list) == old_id) # True