PYTHON / VARIABLES AND DATA TYPES
Mutability and immutability
Tell mutable objects from immutable ones, predict when a change is visible through other names, and copy or rebind deliberately.
What you will learn
- Name Python's mutable builtins (list, dict, set, bytearray) and the immutable ones
- Tell in-place mutation from rebinding by comparing id() before and after
- Copy with list(x), dict(x) or copy.deepcopy() before passing data to mutating code
- Explain why a tuple holding a list is neither frozen nor usable as a dict key
Understanding Mutability and immutability
Mutability is a property of an object's type, not of the name you reach it through. A list, dict, set or bytearray exposes operations that change its contents while the object itself stays the same object: append, update, add, sort, and item assignment all work this way. Numbers, strings, tuples, frozensets and bytes offer no such operation, so every apparent change actually builds a brand-new object. That is why 'ada'.upper() hands you a second string and why there is no str.append at all.
This matters because assignment never copies. When two names refer to the same list, mutating through one name is immediately visible through the other, because there is only one list. That sharing is useful (it is how a function fills in a list you passed it) but it means you must know which operations mutate and which rebind: shopping.append('bread') changes the object everyone sees, while shopping = shopping + ['bread'] leaves the old object untouched and points only that one name at a new list.
Augmented assignment is where the two behaviours collide. x += y first asks the object for an in-place addition; list implements it, so the object's id is unchanged and every other name sees the new items. Tuples and strings do not implement it, so Python falls back to x = x + y, creating a new object and rebinding just that name. Note also that immutability is shallow: a tuple freezes which objects it references, not the state of those objects, which is why a tuple containing a list can still change and cannot be hashed.
shopping = ["eggs", "milk"]
backup = shopping
shopping.append("bread")
print("backup:", backup)
nums = [1, 2]
same = id(nums)
nums += [3]
print("list += in place:", id(nums) == same, nums)
pair = (1, 2)
same = id(pair)
pair += (3,)
print("tuple += new object:", id(pair) == same, pair)
word = "ada"
print("upper():", word.upper(), "original:", word)Mutating an object changes the single object that every reference shares; rebinding a name only changes where that one name points.
Worked examples
An immutable tuple with mutable contents
Shows that a tuple protects its slots but not the objects sitting in those slots.
config = ("dev", ["read"])
config[1].append("write")
print(config)
try:
config[0] = "prod"
except TypeError as e:
print("assign:", e)
try:
{config: 1}
except TypeError as e:
print("hash:", e)Example explained
Line 1config[1].append("write") mutates the inner list; the tuple still references the same list object, so nothing about the tuple changed.
Line 2config[0] = "prod" fails because that would replace which object slot 0 refers to, which is exactly what a tuple forbids.
Line 3Using config as a dict key hashes each element in turn, and hashing the list fails, so an immutable container is only hashable if its contents are.
Shallow copy versus deep copy
Demonstrates that list(x) duplicates the outer list only, leaving nested mutable objects shared.
import copy
original = [[1, 2], [3, 4]]
shallow = list(original)
shallow[0].append(99)
print("original:", original)
deep = copy.deepcopy(original)
deep[0].append(100)
print("original:", original)
print("deep:", deep)Example explained
Line 1list(original) builds a new outer list whose two elements are the very same inner lists, so shallow[0] is original[0].
Line 2That is why appending 99 through shallow is visible in original.
Line 3copy.deepcopy walks the structure and rebuilds every mutable object it finds, so appending 100 to deep[0] leaves original alone.
Mutating methods return None
Contrasts list.sort(), which changes the object in place, with sorted(), which returns a new list.
scores = [3, 1, 2]
result = scores.sort()
print(result, scores)
names = ["bo", "al"]
new = sorted(names)
print(new, names)Example explained
Line 1scores.sort() reorders the existing list object and returns None, a deliberate signal that the useful result is the mutation itself.
Line 2So result is None while scores is the sorted list; writing scores = scores.sort() would throw the data away.
Line 3sorted(names) reads the list and produces a new one, which is why names keeps its original order.
Important notes
Immutability restricts the object, never the name: x = 5 followed by x = 6 does not modify the integer 5, it just points x elsewhere.
t = ([1],) followed by t[0] += [2] raises TypeError, yet the inner list has already been extended to [1, 2] before the failing tuple assignment is attempted.
Common mistakes
Writing b = a for a list and treating b as a copy: any b.append(...) also changes a, because both names label one list.
Writing scores = scores.sort() or text = text.replace('a', 'b') without checking the return convention: sort() returns None so scores becomes None, while replace() does return a new string because strings cannot be edited in place.
Assuming that putting a list inside a tuple freezes it: the list can still be appended to, and the tuple cannot be used as a dict key.
Try it yourself
Change, predict, then run
Create a = {'tags': ['new']}, set b = a, run b['tags'].append('hot') and print a. Then make c = dict(a), append to c['tags'], and print a again to see that a shallow copy still shares the inner list.
Open the Python workspaceCheck your understanding
After a = [1, 2]; b = a; a = a + [3], what does b hold and why?
- [1, 2], because a + [3] built a new list and only rebound the name a
- [1, 2, 3], because b and a always refer to the same list
- [1, 2, 3], because + mutates the list on its left
- TypeError, because you cannot add a list to a list
Show answer
a + [3] creates a third list and the assignment points a at it, leaving the original object (still labelled b) untouched, so b is [1, 2]. The [1, 2, 3] answer would be right for a += [3], which lists implement as an in-place extension of the shared object.