Back to Home

Samrat Kar

exploring & experimenting

Immutability

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:

  1. No copying happens - The reference is passed (efficient!)
  2. Immutable objects can’t be changed - So x = x + 10 creates a NEW integer
  3. Assignment rebinds the local variable to the new object
  4. 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, bool
  • str, bytes
  • tuple
  • frozenset

Mutable (Can Change)

  • list
  • dict
  • set
  • 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