Mutable, Immutable… Everything is an Object!

Mutable, Immutable… Everything is an Object!

Introduction

If you've been coding in Python for a while, you've probably stumbled upon strange bugs where a list changed "by itself," or you've wondered why modifying a string inside a function had no effect outside. The answer lies in one of Python's most elegant (and sometimes maddening) design decisions: everything in Python is an object, and each object has a mutability story to tell.

This post is a deep-dive into that story. We'll explore how Python manages memory under the hood, what makes an object mutable or immutable, and why it fundamentally matters for how you write and debug code — especially when passing data to functions.

🧠 Core Thesis
In Python,every value— integers, strings, lists, even functions — is an object with an identity (id), a type (type), and a value. Whether that object's value can change after creation is what distinguishes mutable from immutable objects.

id and type

Every object in Python has three defining properties: its identity, its type, and its value. Python gives you built-in functions to inspect the first two directly.

The id() function returns a unique integer representing the object's memory address — its identity. The type() function returns its class. Two variables can point to the same object (same id), or to equal-valued but different objects (different id).

# Every object has an id, type, and value
x = 42
print(id(x))      # e.g. 140732856
print(type(x))    # <class 'int'>
print(isinstance(x, int))  # True

# Small integers are cached — same object!
a = 256
b = 256
print(a is b)     # True  (same id)

# Larger integers are NOT cached
c = 1000
d = 1000
print(c is d)     # False (different objects)
print(c == d)     # True  (equal values)        
#Output
140732856
<class 'int'>
True
True
False
True        

The distinction between is (identity comparison) and == (equality comparison) is critical. Python caches small integers between -5 and 256 — so a = 256; b = 256; a is b returns True. But once you go past that range, Python creates a new object, and is returns False even if the values are equal.


Mutable Objects

A mutable object is one whose state (its value) can be changed after it is created — without changing its identity. The object stays at the same memory address, but its contents evolve. The primary mutable built-in types in Python are list, dict, set, and bytearray.

# Lists are mutable
my_list = [1, 2, 3]
print(id(my_list))    # e.g. 140234567

my_list.append(4)     # modify in place
my_list[0] = 99       # change an element
print(my_list)         # [99, 2, 3, 4]
print(id(my_list))    # SAME id — same object!

# Dicts are mutable
d = {'name': 'Alice'}
orig_id = id(d)
d['age'] = 30
print(id(d) == orig_id)  # True — same object

# Aliasing: two names, one object
a = [1, 2, 3]
b = a             # b points to the same list
b.append(4)
print(a)           # [1, 2, 3, 4]  ← a changed too!        
#Output
140234567
[99, 2, 3, 4]
140234567
True
[1, 2, 3, 4]        

The aliasing example above is a common source of bugs. When you write b = a, Python does not copy the list — it creates a second reference to the same object. Mutating through b mutates a as well. To get a genuine copy, use b = a.copy() or b = list(a) for a shallow copy, or copy.deepcopy(a) for a deep copy.

Mutable Types

  • list
  • dict
  • set
  • bytearray
  • User-defined classes (default)

Immutable Types

  • int
  • float
  • str
  • tuple
  • frozenset
  • bytes
  • bool


Immutable Objects

An immutable object cannot be changed after it is created. Any operation that appears to "modify" it actually creates a brand-new object and rebinds the variable to it. The original object remains untouched in memory.

# Strings are immutable
s = "hello"
print(id(s))    # e.g. 140111222

s += " world"    # creates a NEW string object
print(id(s))    # DIFFERENT id — new object!

# Integers behave the same way
n = 10
print(id(n))    # e.g. 9789440
n += 1
print(id(n))    # DIFFERENT id — n now points to int(11)

# You cannot mutate a string in-place
try:
    s[0] = 'H'
except TypeError as e:
    print(e)
# 'str' object does not support item assignment

# Tuples: immutable containers
t = (1, 2, 3)
# t[0] = 99  → TypeError!

# But a tuple CAN contain mutable objects
t2 = ([1, 2], [3, 4])
t2[0].append(99)     # the list inside can change!
print(t2)             # ([1, 2, 99], [3, 4])        
#Output
140111222
140115888
9789440
9789504
'str' object does not support item assignment
([1, 2, 99], [3, 4])        
⚠️ Subtle Trap — Tuple Containing a List
A tuple is immutable, meaning you can't reassign its elements. But if an element is itself a mutable object (like a list), that inner object can still be modified. The tuple's reference to the list doesn't change — only the list's contents do. Immutability is one level deep.

Why Does it Matter?

Understanding mutability is not just academic — it has real performance and correctness implications. Python treats mutable and immutable objects differently in several key ways.

Memory & Interning

Because immutable objects can never change, Python can safely reuse them. This optimization is called interning. Short strings (especially identifiers) and small integers are interned by default — multiple variables holding the same small integer will literally point to the same object in memory, saving allocation overhead.

import sys

# String interning
s1 = "python"
s2 = "python"
print(s1 is s2)    # True — interned!

s3 = "hello world"   # space → likely NOT interned
s4 = "hello world"
print(s3 is s4)    # False (CPython-dependent)

# Force interning with sys.intern()
s5 = sys.intern("hello world")
s6 = sys.intern("hello world")
print(s5 is s6)    # True

# Hash: immutable objects are hashable
my_dict = {(1, 2): "tuple as key"}   # OK ✓
# my_dict[[1, 2]] = "list key"  → TypeError: unhashable type 'list'        
#Output
True
False
True        

Hashability

Because immutable objects can't change, their hash value (used in dict and set lookups) will never change either. That's why only immutable types can be used as dictionary keys or set members. Lists and dicts are unhashable — if you try to use one as a dict key, Python raises TypeError: unhashable type: 'list'.

Concurrency Safety

Immutable objects are inherently thread-safe. No lock is needed to read them because they can never be modified. Mutable shared state is one of the root causes of race conditions in multi-threaded programs.

Article content

How Arguments Are Passed to Functions

Python uses a model called pass-by-object-reference (sometimes called "pass-by-assignment"). When you call a function with an argument, Python passes a reference to the object — not a copy of the object, and not its memory address in the C pointer sense. The function parameter becomes a new name (label) pointing to the same object.

What happens next depends entirely on whether that object is mutable or immutable.

## Case 1: Immutable argument — no side effects
def increment(n):
    print(f"  inside before: id={id(n)}, val={n}")
    n += 1        # creates a NEW int object; rebinds local n
    print(f"  inside after:  id={id(n)}, val={n}")

x = 10
print(f"before call: id={id(x)}, val={x}")
increment(x)
print(f"after call:  id={id(x)}, val={x}")   # still 10!        
#Output
before call: id=9789440, val=10
  inside before: id=9789440, val=10
  inside after: id=9789504, val=11
after call: id=9789440, val=10        

With an immutable integer, the operation n += 1 creates a new object and rebinds the local name n. The original variable x in the caller's scope is completely unaffected.

## Case 2: Mutable argument — side effects!
def add_item(lst, item):
    lst.append(item)    # mutates the object in place
    print(f"  inside: id={id(lst)}")

my_list = [1, 2, 3]
print(f"before: id={id(my_list)}, val={my_list}")
add_item(my_list, 99)
print(f"after:  id={id(my_list)}, val={my_list}")   # changed!

## Case 3: Rebinding inside a function does NOT affect the caller
def reassign(lst):
    lst = [10, 20, 30]   # rebinds LOCAL name, not the caller's
    print(f"  inside: {lst}")

items = [1, 2, 3]
reassign(items)
print(f"  outside: {items}")   # still [1, 2, 3]        
#Output
before: id=140234567, val=[1, 2, 3]
  inside: id=140234567
after: id=140234567, val=[1, 2, 3, 99]
  inside: [10, 20, 30]
  outside: [1, 2, 3]        
💡 The Golden Rule
Mutation is visible. Rebinding is not.If a function mutates a mutable object (e.g.,lst.append(x)), the change is visible to the caller. If it rebinds the local name (e.g.,lst = [new list]), the caller's variable is unaffected.

The Mutable Default Argument Trap

One of the most notorious Python gotchas is using a mutable object as a default argument. Default argument values are evaluated once at function definition time and stored as part of the function object — they are not re-created on each call.

# BUG: mutable default argument
def add_to_cart(item, cart=[]):
    cart.append(item)
    return cart

print(add_to_cart("apple"))    # ['apple']
print(add_to_cart("banana"))   # ['apple', 'banana'] ← bug!
print(add_to_cart("cherry"))   # ['apple', 'banana', 'cherry'] ← bug!

# FIX: use None as sentinel
def add_to_cart_fixed(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart

print(add_to_cart_fixed("apple"))    # ['apple'] ✓
print(add_to_cart_fixed("banana"))   # ['banana'] ✓        
#Output
['apple']
['apple', 'banana']
['apple', 'banana', 'cherry']
['apple']
['banana']        
🔴 Rule: Never Use Mutable Default Arguments
Always useNoneas the default and create the mutable object inside the function body. This is one of the most widely seen Python bugs in real codebases.

Going Deeper: Copying & the Memory Model

When working with nested mutable structures, it's important to understand the difference between a shallow copy and a deep copy.

import copy

original = [[1, 2], [3, 4]]

# Shallow copy: new outer list, but SAME inner lists
shallow = copy.copy(original)
shallow[0].append(99)
print("original:", original)  # [[1, 2, 99], [3, 4]] — affected!
print("shallow:",  shallow)   # [[1, 2, 99], [3, 4]]

# Deep copy: entirely independent structure
original2 = [[1, 2], [3, 4]]
deep = copy.deepcopy(original2)
deep[0].append(99)
print("original2:", original2) # [[1, 2], [3, 4]] — untouched!
print("deep:",      deep)      # [[1, 2, 99], [3, 4]]        
#Output
original: [[1, 2, 99], [3, 4]]
shallow: [[1, 2, 99], [3, 4]]
original2: [[1, 2], [3, 4]]
deep: [[1, 2, 99], [3, 4]]        

Custom Classes and __slots__

User-defined classes are mutable by default — you can add, remove, and change attributes freely. Python stores instance attributes in a __dict__. You can use __slots__ to restrict which attributes are allowed, which also reduces memory usage.

class Point:
    __slots__ = ['x', 'y']   # restricts attribute names

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
p.x = 99          # OK — mutable
print(p.x)         # 99

try:
    p.z = 3         # AttributeError — not in __slots__
except AttributeError as e:
    print(e)

# To make a class truly immutable, use a namedtuple or dataclass(frozen=True)
from dataclasses import dataclass

@dataclass(frozen=True)
class ImmutablePoint:
    x: float
    y: float

ip = ImmutablePoint(3.0, 4.0)
try:
    ip.x = 9        # FrozenInstanceError!
except Exception as e:
    print(e)        
#Output
99
'Point' object has no attribute 'z'
cannot assign to field 'x'        

Conclusion

Python's object model — where everything is an object with an identity, type, and value — is the foundation that mutability is built on. Understanding it means understanding when your data will change and when it won't, which directly impacts the correctness of your programs.

Here's the mental model to carry with you:

🎯 Key Takeaways

1. id() tracks identity, not value. Two equal values can be different objects (different id).        
2. Mutable objects (list, dict, set) can be changed in place — the same object, same id.        
3. Immutable objects (int, str, tuple) cannot — "modifying" them creates a new object with a new id.        
4. Python passes references. Mutating a mutable argument inside a function affects the caller. Rebinding does not.        
5. Never use mutable defaults. Use None and create the object inside the function.        

Mastering this model will save you from entire classes of subtle bugs and help you write faster, more predictable Python. Once you see Python's memory model clearly, the language starts to feel not confusing but beautifully consistent.

This is such a clean and well-explained deep dive 👏 I especially liked how you broke down identity vs equality (is vs ==) and the “mutation vs rebinding” rule — those parts are usually confusing, but your examples made them very intuitive. The sections on aliasing and the mutable default argument trap were also really practical and relatable. You explained a tricky topic in a very clear and structured way — great work Tahmina keep going!

To view or add a comment, sign in

More articles by Tahmina Aliyeva

  • How Do SQL Database Engines Work?

    A Deep Yet Simple Explanation of What Happens Behind Every Query Introduction A simple question can sometimes reveal a…

    4 Comments
  • Consumer Psychology and the Power of Pricing Strategies

    TAHMİNA ALİYEVA | Baku Engineering University | Faculty of Economics and Management | Major of Finance | Sophomore |…

Explore content categories