Python · Fundamentals

Python Mutability — Mutable vs Immutable Types Explained

6 min read Updated 2026-06-17

Practice Mutability & Data Types interview questions

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 singletonsNone, 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 mutablet[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.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.