Mutable vs Immutable Objects

Whether an object can change its own state in place — or must instead be replaced by a new object — governs aliasing, function arguments, and a large share of Python's classic "gotchas."

Every Python object is either mutable (its internal state can change in place while keeping the same identity) or immutable (its state is fixed once created; any "change" actually produces a new object). This distinction quietly shapes how assignment, function calls, and data structures behave, and misunderstanding it is one of the most common sources of subtle bugs for newcomers.

What Mutability Means

Mutable means an object's value can be changed after creation without changing its identity — id(obj) stays the same before and after the change. Immutable means the object's value can never change; any operation that looks like a mutation (like x = x + 1 for an int, or s = s.upper() for a string) actually creates and returns a brand-new object, leaving the original untouched.

The list keeps its identity across mutation; the int gets a new identity because ints cannot change value in place.

# Mutable: same id, value changes
lst = [1, 2]
print(id(lst))
lst.append(3)
print(id(lst))     # same id -- list changed in place

# Immutable: value 'changes' -> new object, new id
n = 5
print(id(n))
n = n + 1
print(id(n))       # different id -- n now refers to a NEW int object

The Immutable Built-in Types

Python's core immutable types are: int, float, bool, complex, str, tuple, frozenset, and bytes. Once created, none of these can change their own value — every apparent modification returns a new object.

String methods never mutate the original string; tuples reject item assignment entirely.

s = "hello"
t = s.upper()
print(s, t)          # 'hello' 'HELLO' -- s is untouched, t is a new string

tup = (1, 2, 3)
# tup[0] = 99        # TypeError: 'tuple' object does not support item assignment

The Mutable Built-in Types

Python's core mutable types are: list, dict, set, and bytearray. These support in-place operations — appending, removing, updating keys, adding elements — that change the object's contents while its identity stays the same. User-defined objects are mutable by default (their attributes can be reassigned) unless the class specifically prevents it (for example, by defining __slots__ combined with read-only properties, or by using @dataclass(frozen=True)).

d = {"a": 1}
d["b"] = 2          # in-place update
print(d)             # {'a': 1, 'b': 2}

s = {1, 2}
s.add(3)             # in-place update
print(s)              # {1, 2, 3}

Tuple Is an Immutable Container, But...

A tuple itself is immutable: you cannot replace, add, or remove its elements. But that immutability is about the container's structure, not about what the elements themselves can do. If a tuple holds a reference to a mutable object (like a list), that inner object can still be mutated — the tuple's slots still point at the exact same objects, so the tuple's own structure hasn't changed.

This means a tuple containing mutable elements is technically unhashable (you cannot put it in a set or use it as a dict key), because its "value" for hashing purposes could change underneath you.

point_history = ([0, 0], [1, 1])
point_history[0].append(99)   # mutating the LIST inside the tuple is fine
print(point_history)           # ([0, 0, 99], [1, 1])

# point_history[0] = [5, 5]    # TypeError -- can't replace a tuple slot

Aliasing and Its Consequences

Aliasing happens when two or more names refer to the same mutable object. Because names are references, an aliased mutable object can be changed through any one of its names, and every other alias immediately sees the change — this is a direct consequence of the object model, not a special feature of mutability.

alias = original does not copy the list; it creates a second name for the same object.

Immutable objects sidestep this issue by construction: since their value can never change, aliasing an immutable object is completely safe — no alias can ever observe a mutation, because none is possible.

original = [1, 2, 3]
alias = original
alias.append(4)
print(original)   # [1, 2, 3, 4] -- surprising if you expected a copy

Side Effects in Function Calls

Because Python passes arguments by object reference ("call by object sharing"), a function that receives a mutable object can mutate it in place, and that change is visible to the caller after the function returns — this is often called a side effect. A function that receives an immutable object cannot produce this kind of side effect: it can only rebind its local parameter name, which never affects the caller.

def add_item(items, item):
    items.append(item)   # mutates the caller's list -- a side effect

def increment(n):
    n = n + 1             # rebinds the LOCAL name n; caller's variable is untouched

cart = ["apple"]
add_item(cart, "banana")
print(cart)              # ['apple', 'banana'] -- caller sees the mutation

x = 5
increment(x)
print(x)                 # 5 -- unchanged; increment only rebound its own local n

Shallow vs Deep Changes

A shallow copy duplicates the outer container but keeps references to the same inner objects; a deep copy recursively duplicates everything, including nested mutable objects. This matters directly for mutability: copying a container of mutable elements with a shallow copy still leaves those inner elements aliased.

Full copy semantics (copy.copy vs copy.deepcopy, __copy__/__deepcopy__ hooks) are covered in depth in a dedicated Copying Objects topic; here the point is only how mutability interacts with copy depth.

import copy

original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)

shallow[0].append(99)
print(original)   # [[1, 2, 99], [3, 4]] -- inner list is shared, so original sees it too

deep[1].append(100)
print(original)   # [[1, 2, 99], [3, 4]] -- deep copy's inner lists are independent

Practical Usage: Choosing Mutable vs Immutable

Prefer immutable types (tuples, frozensets, strings) when a value should never change after creation — this makes code easier to reason about, lets the object be hashed and used as a dict key or set member, and eliminates an entire class of accidental-mutation bugs. Prefer mutable types (lists, dicts, sets) when you genuinely need to build up or modify data incrementally.

# A tuple as a dict key works because tuples of immutable elements are hashable
cache: dict[tuple[int, int], int] = {}
cache[(3, 4)] = 25

# A list can never be a dict key -- it's unhashable (mutable)
# cache[[3, 4]] = 25   # TypeError: unhashable type: 'list'

Key Takeaways

Mutable objects (list, dict, set, bytearray, most user-defined objects) can change value in place while keeping their identity; immutable objects (int, float, bool, str, tuple, frozenset, bytes) cannot.

Any apparent modification of an immutable object actually creates and returns a new object.

A tuple is an immutable container, but if it holds mutable elements, those elements can still be mutated.

Aliasing a mutable object means changes through one name are visible through every other name bound to it.

Function arguments are passed by object reference: mutable arguments can be changed in place (a visible side effect); rebinding a parameter never affects the caller, mutable or not.

A shallow copy protects the outer container but still shares nested mutable objects; use a deep copy to fully decouple nested structures.

def append_item(item, target=[]): target.append(item) return target print(append_item(1)) # [1] print(append_item(2)) # [1, 2] -- surprise! the default is shared and remembered def append_item(item, target=None): if target is None: target = [] target.append(item) return target

A mutable default argument is created ONCE, when the function is defined, and reused across every call that doesn't supply its own value — mutations to it persist between calls. Use None as the default and create a fresh mutable object inside the function body.

new_list = old_list new_list.append(4) # unintentionally mutates old_list too new_list = old_list.copy() # or list(old_list) or old_list[:] new_list.append(4) # old_list is untouched

Plain assignment does not copy a mutable object — it only creates another name for the same object. Use an explicit copy when you need an independent list.

Name Python's core immutable built-in types.

int, float, bool, complex, str, tuple, frozenset, and bytes.

Name Python's core mutable built-in types.

list, dict, set, and bytearray (plus most user-defined objects, by default).

Why can a tuple containing a list not be used as a dict key?

Because the tuple's hash would depend on the mutable list's contents, which could change after insertion — Python refuses to hash a container holding an unhashable (mutable) element.

If a = (1, [2, 3]) and you run a[1].append(4), does this raise an error? Why or why not?

No — the tuple's own slots are unchanged (still pointing at the same list object), so this is legal; only reassigning a[1] itself (a[1] = [9]) would raise TypeError.

Why is aliasing safe for immutable objects but risky for mutable ones?

Immutable objects can never change value, so no alias can ever observe an unexpected change; mutable objects can change in place, so any alias sees every mutation made through any other alias.

What is the practical consequence of pass-by-object-reference for a function that mutates a list argument?

The mutation is visible to the caller after the function returns, because the function was working on the same object the caller holds — this is a genuine side effect, unlike rebinding a parameter.

Why does shallow = list(original) sometimes fail to fully isolate shallow from original?

It copies only the outer list; if the elements themselves are mutable (e.g. nested lists), both original and shallow still reference the SAME inner objects, so mutating a nested element is visible through both.

Write a function safe_double(numbers) that returns a NEW list with every element doubled, without modifying the input list.

def safe_double(numbers): return [n * 2 for n in numbers] original = [1, 2, 3] result = safe_double(original) print(original) # [1, 2, 3] -- untouched print(result) # [2, 4, 6]

A list comprehension builds and returns a brand-new list, leaving the original list object completely unmodified.

What does this code print, and why do the two functions behave differently?

[1, 2, 3, 4] [1, 2, 3] -- lst += [4] calls list's in-place __iadd__, mutating the caller's list; lst = lst + [4] creates a new list and only rebinds the LOCAL name lst, so the caller's b is unaffected.

Write code proving that two equal tuples of immutable elements can be used interchangeably as dict keys.

d = {} d[(1, 2)] = "first" d[(1, 2)] = "second" # same key -- overwrites, doesn't add a new entry print(d) # {(1, 2): 'second'} print(len(d)) # 1

Since tuples of immutable elements are hashable and compare equal by value, two separately-created but equal tuples hash to the same bucket and are treated as the same dict key.

def mutate(lst):
    lst += [4]

def rebind(lst):
    lst = lst + [4]

a = [1, 2, 3]
b = [1, 2, 3]
mutate(a)
rebind(b)
print(a, b)