Mutability in Python: References, Copy and Deepcopy

By Engr. Henry Akpan

"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 == b asks: do the two objects have equal values?
  • a is b asks: are they the same object in memory? (It compares their identities, given by id().)
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

  1. Predict the output: a = [1, 2], b = a, b = b + [3], print(a). Then try b += [3] instead.
  2. Write a function normalise(scores) that returns a new list of scores out of 100 without changing the original list.
  3. Fix: def add_tag(tag, tags=[]): tags.append(tag); return tags.
  4. Create a 4 × 4 board of dots correctly, then place an "X" in one cell.
  5. 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.

Related Tutorials