Mutability, copies and matrices in Python — exercises with a shopping list, a receipt and a warehouse stock (and the product that zip swallowed)
I prepared the receipt for a purchase of four products: bread, milk, eggs and rice. I copied the prices from a piece of paper, ran the program and it gave me a total of 4.55. It seemed low, but I thought “it must be a small shop”. There was no error, not even a warning. When I finally went over the lists I saw that I had four names and only three prices: I had forgotten the one for the rice. And zip did not warn me about anything: it simply stopped when the shorter list ran out and the rice evaporated from the receipt.
The mistakes in this topic all have the same feel: silent, with no exceptions, and with an output that almost makes sense. Today we practise with three exercises of increasing difficulty, three snippets to debug and a cheat sheet to take away.
These exercises use the mutability, copies and matrices theory and review the practice. You can do them without those, but it will be harder.
Table of Contents
How to use this article
- Read the statement of a level and do not look at the solution yet.
- Write your version, run it and compare your output with the expected one.
- If you get stuck, look only at the 🔵 hint and try again.
- When you finish, compare with the solution and notice why it is done that way, not just that it gives the same output.
No exercise uses input(). The data is fixed in the code so that your output is identical to the expected one.
Level 1 · Basic: modifying a shopping list
Statement. Start with the list shopping = ["milk", "bread", "eggs"] and, modifying it in place and in this order: add "rice" at the end, insert "fruit" at position 1, remove the "bread", sort it alphabetically and show it. Then remove the last element storing it in a variable, show that variable together with the list and, finally, show how many elements are left.
Expected output:
['eggs', 'fruit', 'milk', 'rice'] rice ['eggs', 'fruit', 'milk'] 3
You need append, insert, remove and sort (they all modify the list, so do not store their result in any variable). To remove the last one and keep it, pop() does both. Before sorting, the list is ["milk", "fruit", "eggs", "rice"].
Level 2 · Intermediate: the shopping receipt
Statement. With this data:
PRODUCTS = ["Bread", "Milk", "Eggs", "Rice"] PRICES = [1.20, 0.95, 2.40, 1.75] QUANTITIES = [2, 3, 1, 4]
write a function receipt(prices, quantities) that returns a tuple (amounts, total), where amounts is the list with the price times the quantity of each line (computed with zip and a list comprehension) and total is their sum. Then show each product with its amount to two decimals, the total and a list with the products whose amount is over 3 €.
Expected output:
Bread 2.40 Milk 2.85 Eggs 2.40 Rice 7.00 Total: 14.65 Over 3 €: ['Rice']
zip(prices, quantities) gives you (price, quantity) pairs, and the comprehension multiplies each one. To show the products next to the amounts, another zip, this time of PRODUCTS with amounts. The list of the expensive ones is a comprehension with an if at the end.
Level 3 · Challenge: the stock of three warehouses
Statement. The stock of four products in three warehouses is a matrix (one row per warehouse):
STOCK = [
[12, 5, 0, 8],
[3, 9, 14, 0],
[7, 0, 6, 10],
]
Write four functions: totals_per_warehouse(stock) (the sum of each row), totals_per_product(stock) (the sum of each column), transpose(stock) (the matrix with rows and columns swapped) and restock(stock, warehouse, quantity), which returns a new stock with quantity more units of each product in that warehouse without modifying the original. Show the first three results, warehouse 1 restocked with 5 units and the original warehouse 1 to check that it has not changed.
Expected output:
Per warehouse: [25, 26, 23] Per product: [22, 14, 20, 18] Transposed: [[12, 3, 7], [5, 9, 0], [0, 14, 6], [8, 0, 10]] Warehouse 1 restocked: [8, 14, 19, 5] Original: [3, 9, 14, 0]
The sums by rows are a comprehension with sum(row). For the columns you need to go through the column indexes with range(len(stock[0])) and take row[j] from each row; the transpose uses the same idea, but keeping the whole column as a row. In restock, start by copying the matrix row by row.
What is wrong here?
Three snippets with one bug each. Before reading the explanation, do two things: predict whether it raises an error and, if it does not, what it prints.
Snippet 1
receipt.py
names = ["Bread", "Milk", "Eggs", "Rice"]
prices = [1.20, 0.95, 2.40]
total = 0
for name, price in zip(names, prices):
total += price
print(total)
print(len(names), len(prices))
*Think about it before going on: we want to add up the prices of the four products. What does python3 receipt.py print?*
Explanation. There is no error at all, but the answer is wrong:
4.55 4 3
The first line is the total (it should be 6.3!) and the second shows the reason: there are 4 names and only 3 prices. zip stops when the shorter one runs out, so the rice never reaches the loop.
Using zip with lists of different lengths. There is no exception: the extra elements are silently discarded.
Fix. Fill in the missing price and protect the program with an assert that checks the lengths before starting:
names = ["Bread", "Milk", "Eggs", "Rice"]
prices = [1.20, 0.95, 2.40, 1.75]
assert len(names) == len(prices), "missing or extra prices"
total = 0
for name, price in zip(names, prices):
total += price
print(round(total, 2))
print(len(names), len(prices))
6.3 4 4
When two lists must go “in parallel”, an assert len(a) == len(b) at the start turns a silent failure into an error with a message.
Snippet 2
add_item.py
def add_item(product, cart=[]):
cart.append(product)
return cart
monday = add_item("bread")
tuesday = add_item("milk")
print(monday)
print(tuesday)
Does it raise an error? What does it print? Each day should start with an empty cart.
Explanation. It does not raise an error either, and that is why it is treacherous:
['bread', 'milk'] ['bread', 'milk']
Tuesday’s cart has Monday’s bread, and in fact monday and tuesday show the same thing: they are the same list. The default value cart=[] is created only once, when Python reads the function definition, not on every call. All the calls that do not pass a cart share that same list, and each append keeps filling it.
Using a list (or any mutable object) as the default value of a parameter. It is a hidden alias that survives between calls.
Fix. The default value is None, and the list is created inside the function, on every call:
def add_item(product, cart=None):
if cart is None:
cart = []
cart.append(product)
return cart
monday = add_item("bread")
tuesday = add_item("milk")
print(monday)
print(tuesday)
['bread'] ['milk']
The usual recipe is parameter=None and, inside, if parameter is None: parameter = [].
Snippet 3
sold_out.py
cart = ["bread", "out of stock", "out of stock", "milk"]
for product in cart:
if product == "out of stock":
cart.remove(product)
print(cart)
Does it raise an error? What does it print? I want to remove all the sold-out products.
Explanation. It runs without errors, but one “agotado” is left in the cart:
['bread', 'out of stock', 'milk']
You are modifying the list while traversing it. When the first "out of stock" (position 1) is removed, the second one shifts to position 1, but the for is already at 2: it skips it without looking. With two equal elements in a row, one always escapes.
Doing remove or pop on a list inside a for that is traversing it. The indexes move and the loop skips elements.
Fix. Instead of removing, build a new list with only what you want to keep:
cart = ["bread", "out of stock", "out of stock", "milk"] cart = [product for product in cart if product != "out of stock"] print(cart)
['bread', 'milk']
A comprehension with an if at the end is the shortest way of saying “remove everything that meets this”. And since it creates a new list, nothing shifts.
Solutions
Look at this section only when you have tried the exercise. If your code is different but gives the same output and meets the requirements, it is also a valid solution.
Level 1 solution
shopping.py
shopping = ["milk", "bread", "eggs"]
shopping.append("rice")
shopping.insert(1, "fruit")
shopping.remove("bread")
shopping.sort()
print(shopping)
last = shopping.pop()
print(last, shopping)
print(len(shopping))
Why it is done this way:
- All the methods (
append,insert,remove,sort) modify the list in place and returnNone, which is why they are called as standalone statements, withoutx = …. It is exactly the mistake of the list that wasNone. - The step-by-step sequence is:
["milk", "bread", "eggs"]→ withappendit becomes[…, "rice"]→ withinsert(1, "fruit")the fruit sneaks in at position 1 →remove("bread")removes the first one it finds →sort()sorts alphabetically and leaves['eggs', 'fruit', 'milk', 'rice']. pop()does two things at once: it removes the last one (rice) and returns it, which is why we store it inlast. It is the exception to “methods that modify returnNone“.len(shopping)counts what is left after thepop: three products.
Level 2 solution
receipt.py
def receipt(prices, quantities):
"""Returns (amounts, total) with one amount per line."""
amounts = [price * quantity for price, quantity in zip(prices, quantities)]
return amounts, sum(amounts)
PRODUCTS = ["Bread", "Milk", "Eggs", "Rice"]
PRICES = [1.20, 0.95, 2.40, 1.75]
QUANTITIES = [2, 3, 1, 4]
amounts, total = receipt(PRICES, QUANTITIES)
for product, amount in zip(PRODUCTS, amounts):
print(product, f"{amount:.2f}")
print("Total:", f"{total:.2f}")
pricey = [product for product, amount in zip(PRODUCTS, amounts) if amount > 3]
print("Over 3 €:", pricey)
Why it is done this way:
- The function returns
(amounts, total)and does not modify its arguments: it creates the new list of amounts with a comprehension. It can be called with the same data as many times as needed. zip(prices, quantities)pairs the price and the quantity of each line at the same position. Withprice * quantitywe get the amount of each.return amounts, sum(amounts)packs two values into a tuple without parentheses, and the call unpacks them intoamounts, total.- The
forafterwards useszipagain, this time to go throughPRODUCTSandamountsat once, without indexes. The f-string{amount:.2f}gives two decimals: that is how7.0is shown as7.00. priceyis a comprehension with a filter: theifgoes at the end and lets through only the amounts greater than 3. Only the rice (4 × 1.75 = 7.00) meets it; the milk stays at 2.85.- The numbers add up: 2.40 + 2.85 + 2.40 + 7.00 = 14.65.
Level 3 solution
warehouses.py
STOCK = [
[12, 5, 0, 8], # warehouse 0
[3, 9, 14, 0], # warehouse 1
[7, 0, 6, 10], # warehouse 2
]
def totals_per_warehouse(stock):
return [sum(row) for row in stock]
def totals_per_product(stock):
return [sum([row[j] for row in stock]) for j in range(len(stock[0]))]
def transpose(stock):
return [[row[j] for row in stock] for j in range(len(stock[0]))]
def restock(stock, warehouse, quantity):
"""Returns a NEW stock with 'quantity' more units of each product in that warehouse."""
new = [row[:] for row in stock]
new[warehouse] = [units + quantity for units in new[warehouse]]
return new
print("Per warehouse:", totals_per_warehouse(STOCK))
print("Per product:", totals_per_product(STOCK))
print("Transposed:", transpose(STOCK))
restocked = restock(STOCK, 1, 5)
print("Warehouse 1 restocked:", restocked[1])
print("Original:", STOCK[1])
Why it is done this way:
totals_per_warehousegoes by rows: eachrowis a list andsumadds it up whole. One comprehension and done.totals_per_productgoes by columns: “the column” does not exist as an object, so you go through the indexesjand build each column with[row[j] for row in stock], which is then added up. Notice that the range comes fromlen(stock[0]), the number of columns.transposeuses exactly the same idea, but instead of adding it up it keeps each column as a new row: one comprehension inside another. The 3 rows of 4 elements become 4 rows of 3.restockdoes not modifystock: it starts withnew = [row[:] for row in stock], which copies each row, and replaces the warehouse’s row with another new list with the units added. Here, since we replace the whole row, copying the outer list would be enough; we copy the rows anyway so the function stays safe the day you change it to modify individual elements.- The last line of the output demonstrates it: restocked warehouse 1 has
[8, 14, 19, 5], andSTOCK[1]is still[3, 9, 14, 0]. - The numbers add up: column 0 adds up to 12 + 3 + 7 = 22, and the restocked row 1 is 3 + 5, 9 + 5, 14 + 5 and 0 + 5.
Watch it step by step in Python Tutor
The bug in snippet 3 is better seen live. Python Tutor runs the code line by line and draws the lists and their changes. Choose Python 3 and paste this program, which removes the sold-out items in two ways:
cart = ["bread", "out of stock", "out of stock", "milk"]
for product in cart:
if product == "out of stock":
cart.remove(product)
print(cart)
other = ["bread", "out of stock", "out of stock", "milk"]
clean = [p for p in other if p != "out of stock"]
print(clean, other)
Expected output:
['bread', 'out of stock', 'milk'] ['bread', 'milk'] ['bread', 'out of stock', 'out of stock', 'milk']
What to watch, step by step:
- In the first
for,cartis a list of four elements andproductgoes through positions 0, 1, 2 and 3 of the list as it exists at each moment. On the first turn,productis"bread"and nothing happens. - On the second turn,
productis"out of stock"(position 1) and theifis met. After theremove, the list shrinks: it now has three elements and the second"out of stock"has moved to position 1. - The third turn looks at position 2, which is now
"milk". The second"out of stock"is now at position 1, which the loop has already left behind, and it is never examined. - The first
printshows the list with an"out of stock"that has not gone away. With no exception at all: the loop simply skipped an element. - In the second part,
othernever changes (check it in the drawing) andcleanis a new list with only"bread"and"milk". Building instead of modifying avoids the problem.
Cheat sheet: Mutability, copies and matrices
The essentials in 30 seconds
- Mutable:
list(alsodictandset). Immutable: numbers,strandtuple. - **
b = adoes not copy:** it creates an alias. A change throughbshows up ina. - Shallow copy:
l[:],list(l)orl.copy(). For lists of lists,copy.deepcopy(l)or copy each row. - **Methods that modify return
None** (sort,append,remove…);popis the exception, because it returns what it removes. - Sort without touching the original:
sorted(l). Sort in place:l.sort(). - **
zipstops at the shorter list.** If they must be the same length, check the lengths. - New matrix:
[[0] * n for _ in range(m)], never[[0] * n] * m.
Syntax
| I want… | Code | Result |
|---|---|---|
| Add at the end | l.append(x) | Modifies l; returns None |
| Insert at a position | l.insert(pos, x) | Pushes the rest to the right |
| Add several | l.extend(otra) | Each element of otra |
| Remove by value | l.remove(x) | The first one; ValueError if absent |
| Remove and keep it | l.pop() / l.pop(pos) | Returns the element |
| Sort without touching the original | sorted(l, reverse=True) | New list |
| Sort the list itself | l.sort() | Modifies; returns None |
| Copy | l[:] / list(l) / l.copy() | New list (shallow) |
| Copy in depth | copy.deepcopy(l) | The whole tree duplicated |
| Same content / same object | a == b / a is b | True or False |
| Number items | for pos, x in enumerate(l, 1): | (pos, x) pairs from 1 |
| Traverse two lists | for a, b in zip(l1, l2): | Stops at the shorter |
| Transform or filter | [f(x) for x in l if cond] | New list |
| Element of a matrix | m[i][j] | Row i, column j |
| One column | [f[j] for f in m] | List with column j |
| Create a matrix | [[0] * n for _ in range(m)] | m independent rows |
Templates for the exercises
**Receipt with zip and comprehension** (Level 2):
amounts = [price * quantity for price, quantity in zip(prices, quantities)] total = sum(amounts) pricey = [p for p, i in zip(products, amounts) if i > 3]
Totals by rows and by columns (Level 3):
per_row = [sum(row) for row in m] per_column = [sum([row[j] for row in m]) for j in range(len(m[0]))]
Transposing a matrix (Level 3):
transposed = [[row[j] for row in m] for j in range(len(m[0]))]
Copying a matrix without aliases (Level 3):
copy = [row[:] for row in m]
A parameter with a safe default value (Snippet 2):
def add_item(product, cart=None):
if cart is None:
cart = []
cart.append(product)
return cart
Mistakes that repeat the most
| Symptom | Likely cause |
|---|---|
| The “copy” changes when the original changes | It is an alias (copy = items): use l[:] or l.copy() |
A variable is None after sorting or adding | x = l.sort() or x = l.append(…): those methods return None |
ValueError: list.remove(x): x not in list | remove of an absent value: check first with in |
| You change one row of the matrix and all of them change | [[0] * n] * m repeats the same row: create the rows with a comprehension |
| The copy of a matrix changes with the original | Shallow copy: copy each row or use copy.deepcopy |
| The receipt has fewer lines than expected | zip with lists of different lengths: compare the len |
| A function “remembers” data from previous calls | Mutable default parameter (cart=[]): use None |
Elements are left over after a for with remove | You modify the list while traversing it: use a comprehension |
| The function does not change my list | You reassign the parameter (l = l + […]) instead of modifying the object |
TypeError: 'tuple' object does not support item assignment | You try to change an element of a tuple: it is immutable |
Before handing in, ask yourself
- Am I modifying the list or creating a new one? Does my function make it obvious (name and docstring)?
- If I copy something, is it really a copy, and does it go down to the level I am going to modify (rows included)?
- If I use
zip, are my lists the same length? - Have I stored in a variable the result of a method that returns
None(sort,append,remove…)?
If you want to review the topic from the ground up, go back to the mutability, copies and matrices theory.
Keep goingAll Python (en) articlesDid this article help you? Here is how you can support SergioLearns →
