🧠 Python DeepCuts — 💡 Mutability, Immutability & Object Identity
Posted on: November 12, 2025
Description:
Every object in Python has three properties — identity, type, and value.
But not every object behaves the same when modified.
Some can be changed in place (mutable), while others create a new object every time (immutable).
Understanding this difference is essential to avoid subtle bugs and memory surprises.
🧩 Mutable vs Immutable
Mutable objects can change in place — their id() stays the same.
Immutable objects create a new one each time they’re modified.
nums = [1, 2, 3]
print("Before:", id(nums))
nums.append(4)
print("After:", id(nums)) # same ID → mutated in place
name = "AW"
print("\nBefore:", id(name))
name += " Dev"
print("After:", id(name)) # new ID → new object created
🧭 Identity vs Equality
Python separates object identity (is) from value equality (==).
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True → same value
print(a is b) # False → different objects
Two objects can look identical but live at different memory locations.
🧠 Object Caching & Interning
Python optimizes memory by reusing small immutable objects — a technique called interning.
# Integer caching (-5 to 256)
x = 256
y = 256
print(x is y) # True
a = 257
b = 257
print(a is b) # False
# String interning
s1 = "hello"
s2 = "hello"
print(s1 is s2) # True
Small integers and short string literals are cached by default. However, dynamically created strings may not be interned:
x = "".join(["py", "thon"])
y = "".join(["py", "thon"])
print(x is y) # Usually False
🔍 Shared References in Mutable Objects
When two names reference the same object, changes through one name reflect in the other.
a = [1, 2, 3]
b = a
a.append(99)
print(a, b) # both changed
print(a is b) # True
That’s why careless sharing of mutable data structures often leads to hard-to-track bugs.
⚡ Mutable Default Pitfall
A classic gotcha: mutable default arguments.
def add_item(item, container=[]):
container.append(item)
return container
print(add_item("A"))
print(add_item("B")) # accumulates across calls
✅ Fix:
def add_item_fixed(item, container=None):
if container is None:
container = []
container.append(item)
return container
🧬 Reference Counting
Every object tracks how many references point to it.
import sys
x = [1, 2, 3]
print(sys.getrefcount(x)) # 2 (x + function argument)
y = x
print(sys.getrefcount(x)) # 3
del y
print(sys.getrefcount(x)) # back to 2
This is the basis for Python’s memory management and garbage collection.
✅ Key Points
- ✅ Mutable → can change in place (same identity).
- ✅ Immutable → any change creates a new object.
- ✅ is checks identity; == checks value.
- ✅ Python reuses small immutable objects through interning.
- 🧠 Shared references can lead to subtle bugs if not handled carefully.
Code Snippet:
import sys # to inspect object IDs and reference counts
# Mutable example
nums = [1, 2, 3]
print("Before mutation:", nums, "id:", id(nums))
nums.append(4)
print("After mutation:", nums, "id:", id(nums)) # same ID
# Immutable example
name = "AW"
print("\nBefore change:", name, "id:", id(name))
name += " Dev"
print("After change:", name, "id:", id(name)) # new ID
a = [1, 2, 3]
b = [1, 2, 3]
print("a == b:", a == b) # True → same value
print("a is b:", a is b) # False → different objects
print("id(a):", id(a))
print("id(b):", id(b))
# Small integers (-5 to 256) are cached and reused
x = 256
y = 256
print("x is y (256):", x is y) # True → cached
a = 257
b = 257
print("a is b (257):", a is b) # False → new objects
# String interning for small constants
s1 = "hello"
s2 = "hello"
print("\ns1 is s2:", s1 is s2) # True → interned by Python
# Dynamic strings are not always interned
x = "".join(["py", "thon"])
y = "".join(["py", "thon"])
print("x is y:", x is y) # Might be False
a = [1, 2, 3]
b = a
print("Before mutation:", a, b)
a.append(99)
print("After mutation:", a, b) # both changed
print("Same object:", a is b)
def add_item(item, container=[]): # ⚠️ BAD: default is shared
container.append(item)
return container
print(add_item("A"))
print(add_item("B")) # unexpected accumulation
print(add_item("C"))
# Correct approach
def add_item_fixed(item, container=None):
if container is None:
container = []
container.append(item)
return container
print("\nFixed version:")
print(add_item_fixed("A"))
print(add_item_fixed("B"))
x = [1, 2, 3]
print("Ref count for x:", sys.getrefcount(x)) # +1 because of function argument
y = x
print("Ref count after aliasing:", sys.getrefcount(x))
del y
print("After deleting y:", sys.getrefcount(x))
No comments yet. Be the first to comment!