Mutability, copies and matrices in Python — theory with a shopping cart, zip and list comprehension (and the copy that was not a copy)

I wanted to cross things off my shopping list without messing up the original list, so I made a “copy” to work on in peace: copy = cart. I removed the eggs from the copy (I already had some at home), printed both lists to check that the original was still intact, and this is what I saw:

cart = ["bread", "milk", "eggs"]
copy = cart

copy.remove("eggs")
print("Copy:", copy)
print("Cart:", cart)
Copy: ['bread', 'milk']
Cart: ['bread', 'milk']

“How come the eggs are gone from both? I only touched the copy.” I checked the remove, I checked the print, I even doubted my computer. The problem was not in any of those lines: **copy = cart does not copy anything**. It gives the same list a second name, and from that moment on any change shows up on both sides.

Today we find out why. We talk about mutability (which objects can change and which cannot), the methods that modify a list, copies (shallow and deep), how lists behave inside functions, enumerate and zip, list comprehensions and matrices. All with a single example: the shopping cart. If you come from lists and tuples in Python, this is the natural next step: there you learned to read and traverse sequences; here you learn to change them without them turning against you. And when you finish, the practice is waiting with three complete programs.

Mutable and immutable: what it really means

An object is mutable if its content can change without it ceasing to be the same object. It is immutable if it cannot: any “change” actually creates a new object and leaves the old one as it was.

MutableImmutable
listint, float, bool (numbers and booleans)
dict, set (we will see them later)str (strings)
tuple

It is the difference you already saw between lists and tuples, but with deeper consequences than it seems. An immutable number never causes trouble because there is no way to change it “behind your back”. A mutable list does: if two names point to the same list, both see every change. That is what this whole article is about.

Modifying a list

A list accepts changes in place. These are the operations you will use most, with the cart as an example:

cart = ["bread", "milk", "eggs"]

cart[0] = "brown bread"          # assign to a position
cart.append("rice")              # add at the end
cart.insert(1, "fruit")          # insert at a position
cart.extend(["salt", "oil"])     # add several at the end
print(cart)

cart.remove("milk")              # remove by value (the first one found)
last = cart.pop()                # remove and return the last one
second = cart.pop(1)             # remove and return the one at position 1
del cart[0]                      # remove by position, returning nothing
print(cart, last, second)
['brown bread', 'fruit', 'milk', 'eggs', 'rice', 'salt', 'oil']
['eggs', 'rice', 'salt'] oil fruit

Between the lines:

  • cart[0] = "brown bread" replaces whatever was in that position. It is the assignment by index that you could not do with a tuple.
  • append(x) adds one element at the end; insert(pos, x) places it at the position you say and pushes the rest to the right; extend(otra) adds all the elements of another sequence. If instead of extend(["salt", "oil"]) you had written append(["salt", "oil"]), you would have put a list inside the list, as a single element.
  • To remove you have three options: remove(valor) looks for the value and removes the first one it finds; pop() removes and returns the last one (or the one at the position you give, like pop(1)); and del cart[0] deletes by position without returning anything.
  • Look at the order of the lines in the second batch: remove("milk") leaves ['brown bread', 'fruit', 'eggs', 'rice', 'salt', 'oil']; the pop() takes away oil; the pop(1) takes away fruit; and del cart[0] deletes brown bread. That is why ['eggs', 'rice', 'salt'] is what remains at the end.

Using remove with a value that is not in the list. Unlike in, which answers True or False, remove complains:

Traceback (most recent call last):
  File "remove_error.py", line 2, in <module>
    cart.remove("eggs")
ValueError: list.remove(x): x not in list

If you are not sure the product is there, ask first: if "eggs" in cart: cart.remove("eggs").

There is one detail common to all these methods that is worth memorising now: they **modify the list and return None**. They do not give you back the changed list, because the list is already changed. Later you will see why this detail causes so much grief.

Sorting: sorted versus sort

Sorting is the perfect example of the difference between “create a new list” and “modify the one I already have”. Python gives you both versions:

prices = [2.5, 0.8, 1.2, 3.0]

sorted_prices = sorted(prices)       # returns a new list
print(sorted_prices, prices)

prices.sort()                        # sorts the list itself
print(prices)
prices.reverse()                     # turns it around
print(prices)
print(sorted(prices, reverse=True))  # from highest to lowest, without touching the original
[0.8, 1.2, 2.5, 3.0] [2.5, 0.8, 1.2, 3.0]
[0.8, 1.2, 2.5, 3.0]
[3.0, 2.5, 1.2, 0.8]
[3.0, 2.5, 1.2, 0.8]
  • sorted(prices) is a function: it returns a new sorted list and leaves the original as it was. That is why the first line shows two different lists.
  • prices.sort() is a method: it sorts the list itself, in place. And, like every method that modifies, it returns nothing.
  • reverse() does the same with the order: it turns the list around, in place. If what you want is to sort from highest to lowest, sorted(..., reverse=True) (or sort(reverse=True)) does it in one step.

And now, the most famous mistake in the whole article:

None
[0.8, 1.2, 2.5]

Writing sorted_prices = prices.sort() expecting to receive the sorted list. sort() returns None, so sorted_prices is None; the sorted list is prices, which has changed inside. If you want a new variable, use sorted().

The golden rule is that functions that return (sorted) do not touch the original, and methods that modify (sort, append, remove…) return nothing. They almost never do both at once.

Aliases: two names, one list

Let us go back to the mystery at the start. When you write b = a, Python does not copy the list: it makes b point to the same object as a. Variables in Python are labels stuck onto objects, not boxes with the content inside. Two labels on the same object are called aliases:

a = [1, 2, 3]
b = a                  # b is ANOTHER NAME for the same list
b.append(4)
print(a, b)
print(a is b)          # are they the same object?
[1, 2, 3, 4] [1, 2, 3, 4]
True

You change the list through b and see it changed through a, because there is only one list. The is operator confirms it: a is b asks “are they exactly the same object?”, and the answer is True.

Creating a “copy” with copy = items. It is an alias, not a copy. Any change through one shows up in the other, exactly the opposite of what you wanted.

With numbers and strings this has never caused you problems, and it is no coincidence: since they are immutable, even if two names point to the same object, nobody can change it from the inside. If you do x = 5; y = x; y = y + 1, the y + 1 creates a new 6 and ties it to y; the 5 that x sees is still 5. With lists, append or remove do change the object, and that is what every alias sees.

Copies: how to get a real list

When you want an independent list, you have to ask for one. There are three equivalent ways:

original = [1, 2, 3]
c1 = original[:]            # full slice
c2 = list(original)         # constructor
c3 = original.copy()        # method

c1.append(99)
print(original, c1)
print(original == c2, original is c2)   # same content, different object
print(original == c3, original is c3)
[1, 2, 3] [1, 2, 3, 99]
True False
True False

Between the lines:

  • original[:] is a slice from start to end, and you already know that a slice creates a new list. list(original) and original.copy() do the same with another syntax. Use whichever reads best to you.
  • When 99 is added to c1, the original knows nothing about it: they are two different lists.
  • == compares the content and is compares the identity. c2 and c3 have the same content as original (True), but they are not the same object (False). With an alias, both questions would give True.

The practical rule is that == is for asking “do they hold the same thing?” and is for asking “is it literally the same list?”. To compare lists, you will almost always want ==.

Shallow copy and deep copy

The three copies above have a catch. They are shallow: they create a new list, but the elements inside are the same objects as in the original. With numbers you do not notice; with lists inside lists, you do:

import copy

table = [[1, 2], [3, 4]]
shallow = table.copy()
deep = copy.deepcopy(table)

table[0][0] = 99
print("table:", table)
print("shallow:", shallow)
print("deep:", deep)
print(table[0] is shallow[0], table[0] is deep[0])
table: [[99, 2], [3, 4]]
shallow: [[99, 2], [3, 4]]
deep: [[1, 2], [3, 4]]
True False

Look at the second line: we changed table[0][0] and the shallow copy changed too. The explanation is in the last line: table[0] is shallow[0] is True. The shallow copy has a new outer list, but its rows are the same lists as the original’s. The deep copy, with copy.deepcopy, duplicates the whole tree of objects, and that is why deep knows nothing about it.

Copying a list of lists with [:] or .copy() and thinking it is now independent. If you are going to modify elements inside, you need copy.deepcopy or to copy each row by hand.

Mutability and functions

All of the above is especially noticeable when you pass a list to a function. When you call f(cart), the parameter is an alias of your list. Look at four functions that seem alike and are not:

def remove_last(items):
    items.pop()                  # modifies the original list


def add_wrong(items, product):
    items = items + [product]    # creates a NEW list and ties it to the local name


def add_right(items, product):
    items.append(product)        # modifies the original list


def double(number, text):
    number = number * 2          # numbers and strings are immutable
    text = text + "!"


cart = ["bread", "milk", "eggs"]
remove_last(cart)
print(cart)
add_wrong(cart, "rice")
print(cart)
add_right(cart, "rice")
print(cart)

n, t = 5, "hello"
double(n, t)
print(n, t)
['bread', 'milk']
['bread', 'milk']
['bread', 'milk', 'rice']
5 hello

Between the lines:

  • remove_last uses pop(), a method that modifies the list. Since the parameter and your cart are the same list, the change is seen outside: eggs has disappeared.
  • add_wrong does items = items + [product]. That does not modify the list: + creates a new list, and the assignment only ties the local name items to it. When the function ends, that local variable disappears and your cart stays as it was. There is no error or warning: simply nothing happens.
  • add_right does items.append(product), which does change the shared object, and that is why rice appears.
  • double shows the immutable case: number * 2 and text + "!" create new objects, so outside the function n and t are still 5 and "hello".

The rule is this: **assigning to a parameter (items = …) never changes the caller; modifying the object (items.append(…)) does.**

Careful with +=. On a list, items += [product] is equivalent to extend, so it modifies the original; whereas items = items + [product] creates a new list. They look very alike and behave differently.

A function that modifies its arguments can surprise whoever calls it. A good habit is for each function to do one of two things, and for its name and docstring to say which: either it modifies (and returns nothing) or it returns something new (and does not touch the arguments). We talk more about functions in the functions practice.

A tuple that contains a list

There is a case that is baffling at first: a tuple is immutable, but what if one of its elements is a list?

record = ("Lucía", [7.5, 8.0])
record[1].append(9.0)          # the tuple does not change: the list inside it does
print(record)
('Lucía', [7.5, 8.0, 9.0])

The tuple has not changed: it still holds the same two objects (the string and the list). What has changed is the list, which is mutable. What you cannot do is replace an element of the tuple with another:

Traceback (most recent call last):
  File "tuple_list_error.py", line 2, in <module>
    record[1] = []
    ~~~~~~^^^
TypeError: 'tuple' object does not support item assignment

The immutability of a tuple is shallow: it fixes which objects it contains, not what content those objects have.

Traversing with enumerate and zip

In the previous article you traversed by element (for product in products) or by index (for pos in range(len(products))). Python has two functions that cover the in-between cases much more clearly:

products = ["bread", "milk", "eggs"]
prices = [1.20, 0.95, 2.40]

for pos, product in enumerate(products):
    print(pos, product)

for pos, product in enumerate(products, 1):   # start counting at 1
    print(f"{pos}. {product}")

for product, price in zip(products, prices):  # go through two lists at once
    print(f"{product}: {price:.2f} €")

print(list(zip(products, prices)))
0 bread
1 milk
2 eggs
1. bread
2. milk
3. eggs
bread: 1.20 €
milk: 0.95 €
eggs: 2.40 €
[('bread', 1.2), ('milk', 0.95), ('eggs', 2.4)]
  • enumerate(secuencia) gives (position, element) pairs, and for pos, product in … unpacks them into two variables. It is the elegant replacement for range(len(...)) when you need the index and the element at once.
  • enumerate(products, 1) starts counting at 1 instead of 0, ideal for numbering a receipt.
  • zip(a, b) pairs two sequences element by element: the first with the first, the second with the second… That way you go through products and prices at the same time without indexes.
  • zip returns an object that is consumed as you go through it; with list(...) you see the pairs, which are tuples.

Now, the catch with zip:

products = ["bread", "milk", "eggs", "rice"]
prices = [1.20, 0.95, 2.40]

for product, price in zip(products, prices):
    print(product, price)
print(len(list(zip(products, prices))))
bread 1.2
milk 0.95
eggs 2.4
3

Using zip with two lists of different lengths. zip stops as soon as the shorter one runs out, with no error or warning. Here there are four products and three prices: the rice is left without a partner and simply disappears from the traversal.

If your two lists must be the same length, check it before you start with assert len(products) == len(prices). An AssertionError in time saves a lot of debugging.

List comprehension

A pattern you will repeat constantly is building a new list from another: applying something to each element, keeping only some… With a loop it takes three or four lines; with a list comprehension it is one:

prices = [1.20, 0.95, 2.40, 0.80, 3.10]

# with a loop
with_vat = []
for price in prices:
    with_vat.append(round(price * 1.21, 2))

# with a list comprehension: the same in one line
with_vat2 = [round(price * 1.21, 2) for price in prices]
print(with_vat == with_vat2, with_vat2)

cheap_ones = [p for p in prices if p < 1.5]                       # with a condition
labels = ["expensive" if p >= 2 else "cheap" for p in prices]     # with if/else inside
letters = [letter.upper() for letter in "bread"]                  # over any sequence
squares = [n * n for n in range(1, 6)]
print(cheap_ones)
print(labels)
print(letters)
print(squares)
True [1.45, 1.15, 2.9, 0.97, 3.75]
[1.2, 0.95, 0.8]
['cheap', 'cheap', 'expensive', 'cheap', 'expensive']
['B', 'R', 'E', 'A', 'D']
[1, 4, 9, 16, 25]

The general form is:

[expression for element in sequence if condition]

It reads almost like a sentence: “the expression, for each element of the sequence, if it meets the condition”. Between the lines:

  • The first line shows that the loop and the comprehension give exactly the same result (True). It is a shorter way of writing the same traversal with an accumulator.
  • With an if at the end you filter: cheap_ones keeps only the prices below 1.5.
  • With an if/else at the beginning (before the for) you choose between two values for each element: labels produces one label per price, so it has as many elements as prices. Notice the difference: the if only filters and goes at the end; the if/else transforms and goes in front.
  • It works with any sequence, also a string (letters) or a range (squares).
  • A comprehension creates a new list and does not touch the original, just like sorted.

If the comprehension becomes hard to read (several conditions, long calculations), write a normal loop. Fitting on one line does not make it better; being understood does.

To write a comprehension, first write the normal for loop. Then take the expression you were adding with append and put it in front of the for.

Matrices: lists of lists

A matrix is a table of values with rows and columns. In Python it is represented as a list of lists: each element of the outer list is a row. Here, the sales of a three-product shop over three days:

sales = [
    [12, 8, 15],      # row 0: Monday
    [10, 7, 20],      # row 1: Tuesday
    [9, 11, 14],      # row 2: Wednesday
]
print(sales[1])                       # a whole row
print(sales[1][2])                    # row 1, column 2
print(len(sales), len(sales[0]))      # rows and columns

for row in sales:                     # go through it by rows
    print(row, sum(row))

for j in range(len(sales[0])):        # go through it by columns
    column = [row[j] for row in sales]
    print(j, column, sum(column))

sales[0][1] = 80                      # change one element
print(sales[0])
[10, 7, 20]
20
3 3
[12, 8, 15] 35
[10, 7, 20] 37
[9, 11, 14] 34
0 [12, 10, 9] 31
1 [8, 7, 11] 26
2 [15, 20, 14] 49
[12, 80, 15]

Between the lines:

  • sales[1] is the whole of row 1 (a list), and sales[1][2] is row 1, column 2: first you choose the row and then the element inside it. It reads from the outside in.
  • len(sales) is the number of rows, and len(sales[0]) the number of columns (the length of any row, if they are all the same length).
  • Traversing by rows is a normal for: each row is a list, and you can add it up directly.
  • Traversing by columns is different: there is no “column 1” as an object. You have to go through the column numbers with range(len(sales[0])) and build each column with a comprehension, [row[j] for row in sales]. This pattern (go through indexes and take one element from each row) is the key to almost every matrix exercise.
  • sales[0][1] = 80 changes a single element, just like in any list.

And now the classic matrix trap, which combines everything we have seen in the article. You want a 3 by 3 board full of zeros. [0] * 3 gives you a row of three zeros, so why not repeat that row three times?

board = [[0] * 3] * 3
board[0][1] = 5
print(board)
print(board[0] is board[1])
[[0, 5, 0], [0, 5, 0], [0, 5, 0]]
True

Creating a matrix with [[0] * 3] * 3. The outer * 3 does not create three different rows: it repeats the same row three times, that is, three aliases of a single list. That is why changing an element in one row changes it in all of them, and board[0] is board[1] is True.

The solution is to build each row separately, and that is what the comprehension is for:

board = [[0] * 3 for _ in range(3)]
board[0][1] = 5
print(board)
print(board[0] is board[1])
[[0, 5, 0], [0, 0, 0], [0, 0, 0]]
False

Now each turn of the loop runs [0] * 3 again and creates a new list, so the three rows are different objects (False in the is). The _ is just a variable name that says “I am not going to use this value”.

[0] * 3 is safe when the elements are immutable (numbers, strings), because nobody can change them from the inside. The problem appears when you repeat a mutable object, like a list.

Watch it step by step in Python Tutor

Python Tutor runs your program line by line and draws the variables and objects in memory: exactly what you need to understand aliases and copies. Choose Python 3 and paste this program, which captures the mystery from the beginning:

cart = ["bread", "milk"]
alias = cart
copy = cart[:]

alias.append("eggs")
print(cart)
print(copy)
print(cart is alias, cart is copy)

Expected output:

['bread', 'milk', 'eggs']
['bread', 'milk']
True False

What to watch, step by step:

  1. After the second line, there is a single list box with "bread" and "milk", and two arrows pointing to it: the one from cart and the one from alias. Two names, one object.
  2. At copy = cart[:] a second box appears with the same content, and copy points to it. Notice that there are now two different lists in memory.
  3. At alias.append("eggs"), the first box grows. Since cart and alias point to it, **both names see eggs**; the box of copy does not change.
  4. The print calls confirm it: cart has the three products and copy only two.
  5. The last line outputs True False: cart is alias is True (same object) and cart is copy is False (different objects even though they started with the same content). That is exactly the difference between an alias and a copy.

Summary

ConceptThe essentials
Mutable / immutableLists change “inside”; numbers, strings and tuples do not
Modifying a listappend, insert, extend, remove, pop, del: they change the list and return None (except pop)
sorted / sortsorted(l) returns a new list; l.sort() sorts in place and returns None
Aliasb = a does not copy: two names for the same list
Shallow copyl[:], list(l), l.copy(): a new list, but with the same elements
Deep copycopy.deepcopy(l): it duplicates the objects inside too
== / isSame content / same object
FunctionsModifying the object (append) shows outside; reassigning the parameter (l = l + […]) does not
enumerate / zipPosition and element at once / traverse two sequences in parallel (stops at the shorter)
List comprehension[expr for x in seq if cond]: a new list in one line
MatrixList of lists: m[row][column]; by columns, [f[j] for f in m]
Creating a matrix[[0] * n for _ in range(m)], never [[0] * n] * m

Three ideas to take away:

  1. A variable is a label, not a box. b = a sticks a second label on the same object; if the object is mutable, the changes show through both.
  2. There are two ways to sort, copy or filter: modifying or creating a new one. Methods (sort, append) modify and return None; functions and comprehensions (sorted, [… for …]) create a new list. Choosing well avoids half the mistakes in this topic.
  3. A matrix is a list of lists, and each row has to be its own list. If you repeat a row with *, you repeat the same object; create them with a comprehension and copy row by row.

In the mutability, copies and matrices practice you will build a shopping receipt, a cart that gets modified and a price table by store. After that, the exercises will tell you whether it has sunk in. And in the next topic we enter a very different way of solving problems: recursion.

Next step: practiceMutability, copies and matrices in Python — practice with a shopping receipt, a cart that changes and a price table (and the sorted list that was None)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *