Python mutability, explained
Mutability is one of the most consequential ideas in Python. Whether a type can be changed in place determines how assignment behaves, what happens when you pass an object to a function, which objects can be dictionary keys, and a whole family of subtle bugs (the mutable default argument, shared references, aliasing). This guide makes the model concrete and walks through the traps.
Mutable vs immutable types
- Immutable — can never change in place:
int,float,complex,bool,str,tuple,frozenset,bytes,None. - Mutable — can be modified in place:
list,dict,set,bytearray, and most custom objects.
s = "hello"
print(id(s))
s += " world" # looks like mutation...
print(id(s)) # ...but id() changed — a NEW string was created
nums = [1, 2, 3]
print(id(nums))
nums.append(4) # genuine in-place mutation
print(id(nums)) # same id — same object
id(obj) returns an object's identity (its address in CPython); two names with the same
id are the same object. Immutable objects are also hashable, which is why they
can be dict keys and set members.
Names, objects, and aliasing
In Python, variables are names bound to objects, not boxes holding values. Assigning one name to another makes both point at the same object — an alias. Mutating through one alias is visible through the other; rebinding a name is not.
a = [1, 2]
b = a
a.append(3) # mutate -> b sees it; b == [1, 2, 3]
a = [9] # rebind -> b unchanged; b == [1, 2, 3]
This distinction — mutate vs rebind — explains most "why did my other variable change?" confusion.
Passing arguments: pass-by-object-reference
Python is neither pure pass-by-value nor pass-by-reference; it's pass-by-object- reference ("pass by assignment"). A function receives a reference to the same object, so it can mutate a mutable argument in place, but rebinding the parameter doesn't affect the caller.
def mutate(lst): lst.append(4) # caller sees this
def rebind(lst): lst = [0] # caller does NOT see this
data = [1, 2, 3]
mutate(data); print(data) # [1, 2, 3, 4]
rebind(data); print(data) # [1, 2, 3, 4]
Immutable arguments (ints, strings, tuples) can't be mutated, so they appear pass-by-value.
The mutable default argument trap
A default argument is evaluated once, when the function is defined — not on each call. So a mutable default is shared across all calls, accumulating state.
def add(item, bucket=[]): # same list every call
bucket.append(item)
return bucket
add(1) # [1]
add(2) # [1, 2] <- surprise!
The fix is the None sentinel — create a fresh object inside the body:
def add(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
is vs ==
== tests value equality (calls __eq__); is tests identity (same object).
They often agree, but not always.
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — equal contents
a is b # False — distinct objects
Use is only for singletons — None, True, False. Don't use it for numbers or
strings: CPython caches small integers (−5 to 256) and interns some strings, so iscoincidentally works for them and then fails unpredictably outside those ranges. PEP 8
specifically recommends x is None, partly because == None can be overridden by a
custom __eq__.
Copying: shallow vs deep
A shallow copy (copy.copy, list(x), x[:], dict(x), {**d}) duplicates the
outer container but shares the nested objects. A deep copy (copy.deepcopy)
recursively copies everything.
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] <- inner list shared!
deep = copy.deepcopy(original)
deep[0].append(99)
print(original) # unchanged — fully independent
deepcopy even handles circular references safely via an internal memo. Use a shallow
copy when elements are immutable (or sharing is fine); use deepcopy for nested mutables
you'll modify independently.
Hashability: why a list can't be a dict key
Dictionary keys and set members must be hashable, which requires the hash to stay constant for the object's lifetime — i.e. effectively immutable. Lists, dicts, and sets are mutable and unhashable.
{(1, 2): 'ok'} # tuple key
{[1, 2]: 'no'} # TypeError: unhashable type: 'list'
If a key could mutate after insertion, its hash would change and you'd never find it
again. Use a tuple instead of a list, a frozenset instead of a set. For custom
classes, implement __eq__ and __hash__ consistently (equal objects -> equal
hashes), hashing only on fields that never change; defining __eq__ alone makes a class
unhashable.
Two more traps
A tuple is immutable, but its contents can be mutable — t[0].append(x) works if
t[0] is a list (and t[0] += [x] both mutates the list and raises a TypeError).
And list multiplication copies references:
grid = [[]] * 3
grid[0].append(1)
print(grid) # [[1], [1], [1]] — all share one list!
grid = [[] for _ in range(3)] # three distinct lists
Also avoid modifying a list while iterating over it (it skips elements); build a new list
with a comprehension instead. And remember del name unbinds the name, not necessarily
the object (which lives until its refcount hits zero).
Recap
Python variables are names bound to objects; whether a type is mutable decides
everything downstream. Aliases share mutations but not rebinding; arguments are
passed by object reference, so functions can mutate but not rebind. Use the None
sentinel for mutable defaults, is only for singletons, and deepcopy for independent
nested copies. Immutable types are hashable (dict keys, set members); mutable ones aren't.
Keep the mutate-vs-rebind distinction in mind and Python's reference semantics stop
surprising you.