Mutability in Python: References, Copy and Deepcopy
"I Changed One List and the Other Changed Too!"
You copy a list with b = a, add an item to b, and discover that a has changed as well. You build a 3 × 3 grid, set one cell, and a whole column changes. A function with a default list argument seems to "remember" values from earlier calls. These are among the most common Python bugs, and they all come from one idea: Python variables are references to objects, and some objects are mutable (changeable) while others are not. This tutorial explains mutability in Python clearly: names and objects, == versus is, mutable and immutable types, aliasing, how arguments are passed to functions, the mutable default argument trap, the list-of-lists trap, and shallow copies versus deep copies with the copy module. All outputs were produced by running the code on Python 3.13.
Names Refer to Objects
In Python, an assignment like a = [1, 2, 3] does two things: it creates a list object in memory, and it makes the name a refer to that object. A variable is like a label tied to an object, not a box that contains it.
So b = a does not copy the list. It ties a second label to the same object:
a = [1, 2, 3] b = a b.append(4) print(a) print(b) print(a is b)
Output:
[1, 2, 3, 4] [1, 2, 3, 4] True
Two names referring to the same object are called aliases. Changing the object through either name is visible through both.
== Versus is
a == basks: do the two objects have equal values?a is basks: are they the same object in memory? (It compares their identities, given byid().)
x = [1, 2, 3] y = [1, 2, 3] z = x print(x == y, x is y) print(x == z, x is z) print(id(x) == id(z))
Output:
True False True True True
x and y are equal but separate lists. Use == to compare values. Use is mainly for checking against None: if result is None:.
Mutability in Python: Mutable and Immutable Types
An object is mutable if it can be changed in place after it is created, and immutable if it cannot.
- Immutable:
int,float,bool,str,tuple,frozenset,None. - Mutable:
list,dict,set, and most objects of classes you write yourself.
With immutable objects, every "change" actually creates a new object and moves the label:
s = "hello"
t = s
s = s.upper() # creates a NEW string; t still refers to the old one
print(s, t)
n = 10
m = n
n += 5 # creates a new int object 15
print(n, m)
try:
s[0] = "J"
except TypeError as e:
print("Error:", e)
Output:
HELLO hello 15 10 Error: 'str' object does not support item assignment
This is why aliasing never causes surprises with numbers and strings: nothing can change them in place. With lists, dictionaries and sets, methods like append(), update(), add(), sort() and item assignment change the object itself, which every alias sees.
+= behaves differently for lists
a = [1, 2] b = a a += [3] # in-place for lists: modifies the shared object print(b) a = [1, 2] b = a a = a + [3] # builds a NEW list and rebinds a print(b)
Output:
[1, 2, 3] [1, 2]
How Python Passes Arguments to Functions
Python passes arguments by object reference (sometimes called "pass by assignment"). The parameter becomes another name for the same object the caller passed in. So:
- If the function mutates a mutable argument, the caller sees the change.
- If the function rebinds the parameter to a new object, the caller's variable is unaffected.
def add_bonus(scores):
for i in range(len(scores)):
scores[i] += 5 # mutates the caller's list
def reset(scores):
scores = [0, 0, 0] # rebinds the local name only
marks = [60, 70, 80]
add_bonus(marks)
print(marks)
reset(marks)
print(marks)
Output:
[65, 75, 85] [65, 75, 85]
A good habit is to make it obvious when a function modifies its argument, or to return a new list instead: return [s + 5 for s in scores].
The Mutable Default Argument Trap
Default values are created once, when the function is defined, not each time it is called. A mutable default is therefore shared between calls:
def register(course, students=[]):
students.append(course)
return students
print(register("CSC 101"))
print(register("MTH 101")) # surprise!
Output:
['CSC 101'] ['CSC 101', 'MTH 101']
The standard fix uses None as the default and creates a fresh list inside the function:
def register(course, students=None):
if students is None:
students = []
students.append(course)
return students
print(register("CSC 101"))
print(register("MTH 101"))
Output:
['CSC 101'] ['MTH 101']
The List-of-Lists Trap
Multiplying a list repeats references, not copies. For a list of numbers that is harmless, but for a list of lists it is a classic bug:
grid = [[0] * 3] * 3 # three references to ONE row grid[0][0] = 1 print(grid) grid = [[0] * 3 for _ in range(3)] # three separate rows grid[0][0] = 1 print(grid)
Output:
[[1, 0, 0], [1, 0, 0], [1, 0, 0]] [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
The list comprehension builds a new inner list on every pass, which is what you want for game boards, matrices and seating plans.
Shallow Copies
To get an independent copy of a list, you must copy it explicitly. All of these make a shallow copy: a new outer list containing references to the same items.
import copy original = [1, 2, 3] c1 = original.copy() c2 = original[:] c3 = list(original) c4 = copy.copy(original) c1.append(99) print(original, c1) print(all(c is not original for c in [c1, c2, c3, c4]))
Output:
[1, 2, 3] [1, 2, 3, 99] True
For dictionaries, use d.copy() or dict(d), and for sets, s.copy() or set(s).
Where shallow copies fall short
If the items are themselves mutable, such as lists inside a list, a shallow copy shares them:
class_list = [["Ada", 72], ["Bayo", 45]] backup = class_list.copy() # shallow class_list[0][1] = 90 # change an inner list class_list.append(["Chidi", 88]) # change the outer list print(backup)
Output:
[['Ada', 90], ['Bayo', 45]]
The backup did not get Chidi (the outer lists are separate), but it did get Ada's new score, because both outer lists refer to the same inner lists.
Deep Copies
copy.deepcopy() copies the outer object and, recursively, every mutable object inside it, so the copy is completely independent:
import copy
class_list = [["Ada", 72], ["Bayo", 45]]
backup = copy.deepcopy(class_list)
class_list[0][1] = 90
print(class_list)
print(backup)
print(class_list[0] is backup[0])
record = {"name": "Ada", "courses": ["CSC 101", "MTH 101"]}
snapshot = copy.deepcopy(record)
record["courses"].append("PHY 101")
print(snapshot["courses"])
Output:
[['Ada', 90], ['Bayo', 45]] [['Ada', 72], ['Bayo', 45]] False ['CSC 101', 'MTH 101']
Deep copying is slower and uses more memory, so use it only when you really need a fully independent snapshot of nested data, such as saving a game state or keeping an "undo" history.
Tuples Containing Mutable Objects
A tuple is immutable: you cannot replace its items. But if an item is a list, the list itself can still change:
t = ("Ada", [72, 80])
t[1].append(91)
print(t)
try:
hash(t)
except TypeError as e:
print("Error:", e)
print(hash(("Ada", 72)) == hash(("Ada", 72)))
Output:
('Ada', [72, 80, 91])
Error: unhashable type: 'list'
True
Only hashable objects, whose value can never change, can be dictionary keys or set members. That is why you can use a tuple of numbers or strings as a key, but not a list, or a tuple containing a list.
Mutability in Python: Practice Exercises
- Predict the output:
a = [1, 2],b = a,b = b + [3],print(a). Then tryb += [3]instead. - Write a function
normalise(scores)that returns a new list of scores out of 100 without changing the original list. - Fix:
def add_tag(tag, tags=[]): tags.append(tag); return tags. - Create a 4 × 4 board of dots correctly, then place an "X" in one cell.
- Explain why
copy.copy()is not enough to back up a list of dictionaries.
Answer to 1: b = b + [3] creates a new list, so a stays [1, 2]. With b += [3], the shared list is changed in place, so a becomes [1, 2, 3].
Conclusion
Mutability in Python starts from one idea: variables are labels that refer to objects, so assignment never copies. Use == to compare values and is to check identity. Immutable objects such as numbers, strings and tuples cannot change, so aliases are harmless, but lists, dictionaries and sets can be changed in place, and every alias sees the change. Functions receive references to the caller's objects, mutable default arguments are shared between calls, and multiplying a list of lists shares the inner lists. Make shallow copies with .copy() or slicing, and use copy.deepcopy() when nested data must be fully independent.