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.

How to use this article

  1. Read the statement of a level and do not look at the solution yet.
  2. Write your version, run it and compare your output with the expected one.
  3. If you get stuck, look only at the 🔵 hint and try again.
  4. 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 return None, which is why they are called as standalone statements, without x = …. It is exactly the mistake of the list that was None.
  • The step-by-step sequence is: ["milk", "bread", "eggs"] → with append it becomes […, "rice"] → with insert(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 in last. It is the exception to “methods that modify return None“.
  • len(shopping) counts what is left after the pop: 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. With price * quantity we get the amount of each.
  • return amounts, sum(amounts) packs two values into a tuple without parentheses, and the call unpacks them into amounts, total.
  • The for afterwards uses zip again, this time to go through PRODUCTS and amounts at once, without indexes. The f-string {amount:.2f} gives two decimals: that is how 7.0 is shown as 7.00.
  • pricey is a comprehension with a filter: the if goes 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_warehouse goes by rows: each row is a list and sum adds it up whole. One comprehension and done.
  • totals_per_product goes by columns: “the column” does not exist as an object, so you go through the indexes j and build each column with [row[j] for row in stock], which is then added up. Notice that the range comes from len(stock[0]), the number of columns.
  • transpose uses 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.
  • restock does not modify stock: it starts with new = [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], and STOCK[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:

  1. In the first for, cart is a list of four elements and product goes through positions 0, 1, 2 and 3 of the list as it exists at each moment. On the first turn, product is "bread" and nothing happens.
  2. On the second turn, product is "out of stock" (position 1) and the if is met. After the remove, the list shrinks: it now has three elements and the second "out of stock" has moved to position 1.
  3. 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.
  4. The first print shows the list with an "out of stock" that has not gone away. With no exception at all: the loop simply skipped an element.
  5. In the second part, other never changes (check it in the drawing) and clean is 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 (also dict and set). Immutable: numbers, str and tuple.
  • **b = a does not copy:** it creates an alias. A change through b shows up in a.
  • Shallow copy: l[:], list(l) or l.copy(). For lists of lists, copy.deepcopy(l) or copy each row.
  • **Methods that modify return None** (sort, append, remove…); pop is the exception, because it returns what it removes.
  • Sort without touching the original: sorted(l). Sort in place: l.sort().
  • **zip stops 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…CodeResult
Add at the endl.append(x)Modifies l; returns None
Insert at a positionl.insert(pos, x)Pushes the rest to the right
Add severall.extend(otra)Each element of otra
Remove by valuel.remove(x)The first one; ValueError if absent
Remove and keep itl.pop() / l.pop(pos)Returns the element
Sort without touching the originalsorted(l, reverse=True)New list
Sort the list itselfl.sort()Modifies; returns None
Copyl[:] / list(l) / l.copy()New list (shallow)
Copy in depthcopy.deepcopy(l)The whole tree duplicated
Same content / same objecta == b / a is bTrue or False
Number itemsfor pos, x in enumerate(l, 1):(pos, x) pairs from 1
Traverse two listsfor 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 matrixm[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

SymptomLikely cause
The “copy” changes when the original changesIt is an alias (copy = items): use l[:] or l.copy()
A variable is None after sorting or addingx = l.sort() or x = l.append(…): those methods return None
ValueError: list.remove(x): x not in listremove 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 originalShallow copy: copy each row or use copy.deepcopy
The receipt has fewer lines than expectedzip with lists of different lengths: compare the len
A function “remembers” data from previous callsMutable default parameter (cart=[]): use None
Elements are left over after a for with removeYou modify the list while traversing it: use a comprehension
The function does not change my listYou reassign the parameter (l = l + […]) instead of modifying the object
TypeError: 'tuple' object does not support item assignmentYou try to change an element of a tuple: it is immutable

Before handing in, ask yourself

  1. Am I modifying the list or creating a new one? Does my function make it obvious (name and docstring)?
  2. If I copy something, is it really a copy, and does it go down to the level I am going to modify (rows included)?
  3. If I use zip, are my lists the same length?
  4. 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) articles

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 *