Lists, tuples, dicts and sets · about 45 min

Mutability, aliasing and copying

Why this matters

This lesson explains the bug behind countless confused forum posts: "I changed one list and another one changed too!" It happens because Python names are references to objects, and some objects (lists, dicts, sets) can change in place. Once you see what is really going on in memory, the behavior becomes predictable, and you will know exactly when to copy, how deep to copy, and how to write functions that do not surprise their callers.

The model: names refer to objects

From the start of this track you have seen that assignment binds a name to an object. It never copies the object:

a = [1, 2, 3]
b = a
 a ---+
      +---> [1, 2, 3]     one list object
 b ---+

a and b are two names for the same list. This is called aliasing. You can check it with is, which compares identity (same object), versus ==, which compares value (equal contents):

a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a == b, a is b)
print(a == c, a is c)
True True
True False

c is a different list that happens to have equal contents.

Mutation versus rebinding

Two very different things can happen to a from here:

Mutating the object changes it in place. Every name referring to it sees the change:

a = [1, 2, 3]
b = a
a.append(4)        # mutate
a[0] = 100         # mutate
print(b)
[100, 2, 3, 4]

Rebinding a name points it at a different object. Other names are unaffected:

a = [1, 2, 3]
b = a
a = [7, 8]         # rebind a to a new list
print(b)
[1, 2, 3]

Ask yourself of every line: does it change an object (method like append, sort, item assignment x[i] = ..., del x[i], += on a list), or does it make a name refer to something else (plain name = ...)?

Mutable and immutable types

Immutable (cannot change after creation)Mutable (can change in place)
int, float, bool, Nonelist
strdict
tuple, frozensetset

With immutable objects, aliasing is never a problem, because nobody can change the shared object. Every "change" makes a new one:

s = "hi"
t = s
s += "!"          # builds a new string, rebinds s
print(t)          # hi

This is why += behaves differently by type. On a list, x += [3] calls x.extend([3]), mutating in place, so aliases see it. On a tuple or string, += must create a new object and rebind the name.

Aliasing in disguise

Aliasing does not only come from b = a. Any time a reference is stored, you get another way to reach the same object.

Containers hold references. Putting a list into another list stores a reference:

row = [0, 0]
grid = [row, row]
grid[0][0] = 5
print(grid)
[[5, 0], [5, 0]]

List multiplication repeats references. This is the famous grid trap:

grid = [[0] * 3] * 3
grid[1][1] = 1
print(grid)
[[0, 1, 0], [0, 1, 0], [0, 1, 0]]
 grid ---> [ ref, ref, ref ]
              \    |    /
               v   v   v
             [0, 1, 0]        only one row list exists

[0] * 3 is fine, because the repeated item 0 is an immutable int. Repeating a mutable object gives several references to that single object. Build rows in a loop so each iteration creates a fresh list:

grid = []
for _ in range(3):
    grid.append([0] * 3)

(_ is a conventional name for a loop variable you do not use.)

Function arguments are aliases. Calling a function binds its parameter to the caller's object, with no copy. So a function can mutate the caller's list:

def add_bonus(scores):
    for i in range(len(scores)):
        scores[i] += 10

mine = [50, 60]
add_bonus(mine)
print(mine)
[60, 70]

Sometimes that is the point (list.sort() works this way and returns None to signal it). Often it is an accident. Rebinding the parameter, on the other hand, never affects the caller:

def reset(scores):
    scores = []        # rebinds the local name only

mine = [50, 60]
reset(mine)
print(mine)            # [50, 60]

People sometimes ask whether Python is "pass by value" or "pass by reference". The accurate answer is that Python passes references to objects (sometimes called "pass by object reference" or "call by sharing"): the function can mutate a mutable object it receives, but cannot make the caller's name refer to something else.

A sensible rule: functions should not mutate their arguments unless that is their documented purpose. Return a new object instead.

Copying

When you do want an independent object, copy it explicitly.

Shallow copies create a new outer container holding the same element references:

a = [1, 2, 3]
b = a.copy()       # also: list(a), a[:]
b.append(4)
print(a, b)        # [1, 2, 3] [1, 2, 3, 4]

Dicts and sets have .copy() too, and dict(d) and set(s) also copy.

A shallow copy is enough when the elements are immutable. With nested mutable elements, the inner objects are still shared:

outer = [[1, 2], [3]]
shallow = outer.copy()
shallow.append([4])        # only shallow changes: outer lists differ
shallow[0].append(99)      # inner list is shared!
print(outer)
print(shallow)
[[1, 2, 99], [3]]
[[1, 2, 99], [3], [4]]
 outer   ---> [ ref, ref ]
                 |    \
                 v     v
               [1,2,99] [3]        inner lists shared
                 ^     ^
 shallow ---> [ ref, ref, ref ]
                           \
                            v
                           [4]

Deep copies recursively copy everything reachable, giving a fully independent structure:

import copy
outer = [[1, 2], [3]]
deep = copy.deepcopy(outer)
deep[0].append(99)
print(outer, deep)
[[1, 2], [3]] [[1, 2, 99], [3]]

deepcopy is slower and uses more memory, and it copies everything, which is not always what you want (for example, objects that represent an open file or a shared configuration). Use it when you need a truly independent snapshot of nested data.

Where the default-argument trap fits

Recall the mutable default from the parameters lesson: def add_item(item, basket=[]). Now you can describe it precisely: the default list is one object, created once at def time, and every call that omits basket gets an alias to that same object. Mutating it mutates the shared default.

Common mistakes

Recap

Going further: paste the examples from this lesson into Python Tutor (pythontutor.com), which draws names and objects as boxes and arrows. Then read the documentation of the copy module, including how classes can customize copying with __copy__ and __deepcopy__.

Exercises

Exercise 1 · quiz

Two names, one list

What does this print?

a = [1, 2, 3]
b = a
b.append(4)
a = a + [5]
print(a, b)
Answer
  • [1, 2, 3, 4, 5] [1, 2, 3, 4]

b = a makes both names refer to the same list, so b.append(4) is visible through a. Then a + [5] builds a brand-new list and rebinds a to it; b still refers to the old list [1, 2, 3, 4]. Mutation changes an object; assignment only moves a name.

Exercise 2 · quiz

The grid trap

What does this print?

grid = [[0] * 3] * 2
grid[0][0] = 9
print(grid)
Answer
  • [[9, 0, 0], [9, 0, 0]]

[[0] * 3] * 2 creates one inner list and an outer list holding two references to it. Changing "row 0" changes the only row there is, seen twice. Build independent rows with a loop (or a comprehension, next module): each iteration must create a new inner list.

Exercise 3 · quiz

+= on lists versus tuples

What does this print?

x = [1, 2]
y = x
x += [3]

s = (1, 2)
t = s
s += (3,)

print(y, t)
Answer
  • [1, 2, 3] (1, 2)

For a list, += mutates the list in place (it calls extend), so the alias y sees the change. Tuples are immutable, so s += (3,) must build a new tuple and rebind s, leaving t pointing at the original. The same operator, different behavior, decided by mutability.

Exercise 4 · write code

Build an independent grid

Define make_grid(rows, cols, fill) that returns a list of rows lists, each containing cols copies of fill, where every row is a separate list object, so changing one cell changes only that cell:

g = make_grid(2, 3, 0)    # [[0, 0, 0], [0, 0, 0]]
g[0][1] = 7               # [[0, 7, 0], [0, 0, 0]]

File: solution.py

Starting point

def make_grid(rows, cols, fill):
    return [[fill] * cols] * rows
Hint 1

[row] * rows repeats references to one row object.

Hint 2

Create the row inside a loop so that each iteration makes a new list: grid.append([fill] * cols).

Reference solution
def make_grid(rows, cols, fill):
    """Return a rows x cols grid of independent lists, filled with fill."""
    grid = []
    for r in range(rows):
        grid.append([fill] * cols)
    return grid

Exercise 5 · write code

Do not surprise the caller

normalized(scores) should return a new list where each score is divided by the maximum score (so the top score becomes 1.0). The starter version returns the right numbers but modifies the caller's list, a nasty side effect. Fix it so the argument is left untouched.

data = [5, 10, 2]
result = normalized(data)   # [0.5, 1.0, 0.2]
data                        # still [5, 10, 2]

Assume the list is non-empty and the maximum is positive.

File: solution.py

Starting point

def normalized(scores):
    top = max(scores)
    for i in range(len(scores)):
        scores[i] = scores[i] / top
    return scores
Hint 1

The parameter scores is another name for the caller's list; assigning scores[i] = ... changes that list.

Hint 2

Build and return a separate result list instead.

Reference solution
def normalized(scores):
    """Return a new list of scores divided by the maximum score."""
    top = max(scores)
    result = []
    for s in scores:
        result.append(s / top)
    return result

Exercise 6 · write code

Shallow or deep?

The starter makes a "backup" of a nested list with .copy(), modifies the original, and prints both. Because the copy is shallow, the backup changes too. Change only the line that makes the backup so that the output is exactly:

original: [['a', 'b', 'X'], ['c']]
backup:   [['a', 'b'], ['c']]

File: main.py

Starting point

import copy

original = [["a", "b"], ["c"]]
backup = original.copy()
original[0].append("X")
print("original:", original)
print("backup:  ", backup)

Example

expected output

original: [['a', 'b', 'X'], ['c']]
backup:   [['a', 'b'], ['c']]
Hint 1

.copy() copies the outer list but both lists still share the inner lists.

Hint 2

copy.deepcopy(original) copies the inner lists too.

Reference solution
import copy

original = [["a", "b"], ["c"]]
backup = copy.deepcopy(original)
original[0].append("X")
print("original:", original)
print("backup:  ", backup)