Which Python built-in types are mutable and which are immutable, and why does it matter?
Immutable types — int, float, bool, str, bytes, tuple, frozenset — cannot be changed after creation; operations return new objects. Mutable types — list, dict, set, bytearray — can be changed in place. Mutability determines hashability (only immutables can be dict keys/set members), function side-effect behaviour, and thread-safety considerations.
How to think about it
Mutability is a gateway question — answer it well and you’ve pre-answered a half-dozen follow-ups: “why can’t a list be a dict key?”, “why did my function change the caller’s list?”, “why doesn’t x = x + 1 mutate x?”. A strong answer lays out the table, separates rebinding from mutation, and shows the function side-effect that follows from it.
| Type | Mutable | Hashable | Notes |
|---|---|---|---|
int, float, bool | No | Yes | small ints cached by CPython |
str | No | Yes | many literals interned |
tuple | No | Yes (if contents are) | immutable one level deep |
frozenset | No | Yes | the immutable set |
bytes | No | Yes | |
list | Yes | No | |
dict | Yes | No | keys must be hashable |
set | Yes | No | elements must be hashable |
bytearray | Yes | No | the mutable bytes |
Rebinding is not mutation
The single most common confusion: reassigning a name doesn’t change the object it pointed at — it just points the name somewhere new.
x = 5
x = 6 # rebinding — x now names int 6; the int 5 is untouched
lst = [1, 2]
lst.append(3) # mutation — the same list object is changed in place
lst = [1, 2] # rebinding — lst now names a brand-new list
A worked example
# 1. Immutable: x + 1 builds a NEW int, so identity changes
x = 42
original_id = id(x)
x = x + 1
print(f"x after +1: {x}, same object? {id(x) == original_id}")
# 2. Mutable: append changes the SAME list, identity is unchanged
lst = [1, 2]
original_id = id(lst)
lst.append(3)
print(f"lst after append: {lst}, same object? {id(lst) == original_id}")
# 3. The practical consequence — function side effects
def double(n):
n *= 2 # rebinds the local name; caller is untouched
def append_zero(seq):
seq.append(0) # mutates the SAME object the caller passed in
x = 5
double(x)
print(f"x after double(): {x}")
nums = [1, 2]
append_zero(nums)
print(f"nums after append_zero(): {nums}")
# 4. Hashability — only immutables can be keys or set members
lookup = {frozenset({1, 2}): "pair", frozenset({3}): "single"}
print("frozenset as dict key:", lookup[frozenset({1, 2})])
# 5. A tuple is immutable only one level deep
t = ([1, 2], 3)
t[0].append(99) # the LIST inside the tuple is still mutable
print("Tuple after inner mutation:", t)
try:
hash(t) # ...which is why hashing it fails
except TypeError as e:
print("Hash error:", e)
x after +1: 43, same object? False
lst after append: [1, 2, 3], same object? True
x after double(): 5
nums after append_zero(): [1, 2, 0]
frozenset as dict key: pair
Tuple after inner mutation: ([1, 2, 99], 3)
Hash error: unhashable type: 'list'
Two results carry the whole lesson. double(x) left x at 5 — inside the function n *= 2 just rebound a local name — while append_zero(nums) reached into the caller’s list and changed it to [1, 2, 0]. That difference — mutable passed by reference versus immutable rebound locally — is the source of countless real bugs.
Why it matters
Hashability. Only immutable objects can be dict keys or set members, because their hash must never change. A mutable key could be altered after insertion and the hash table would lose track of it. Reach for frozenset when you need a set that can itself be a key or an element.
Function side effects. Hand a list to a function and it can mutate it in place; the caller sees every change. Hand an int or str and the function can only rebind its local name — the caller’s variable is safe.