Module 3 ended by pointing at a crack: the Papyrus catalog lives spread across three parallel lists (catalog, prices, stocks) that Ana has to keep in sync by hand. Before we fix that crack with dictionaries (lesson 04-03), you need to truly master the structure you have been using since module 1 almost without looking at it: the list. So far you have looped over lists with for, enumerate() and zip(), and used append() in passing; in this lesson we open them up: indexing, slicing, all their important methods and — the most delicate part — what it means for them to be mutable.

Contents

  1. Creating lists
  2. Positive and negative indexing
  3. Slicing in depth
  4. List methods: adding, removing and searching
  5. Sorting: sort() vs sorted() and reverse()
  6. Mutability: aliases vs copies
  7. Nested lists
  8. Membership: in and not in

Creating lists

A list is an ordered, mutable sequence of elements, which can be of any type (even mixed, although that is rarely a good idea).

# Papyrus's three parallel lists, exactly as module 3 left them
catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]
stocks = [4, 6, 8]

empty = []                    # empty list, ready to be filled
also_empty = list()           # equivalent
from_range = list(range(5))   # [0, 1, 2, 3, 4]: list() converts iterables
letters = list("Ana")         # ['A', 'n', 'a']: a string is iterable too
  • [] creates a list literal; list(iterable) builds a list out of anything you can loop over.
  • Remember from module 2 that comprehensions exist too: [p * 1.04 for p in prices] creates a new, computed list. Here we focus on manipulating lists that already exist.

Positive and negative indexing

Each element has a position (index) starting at 0. Negative indices count from the end: -1 is the last element.

catalog = ["The Odyssey", "Hamlet", "Don Quixote"]

print(catalog[0])    # The Odyssey   (first)
print(catalog[2])    # Don Quixote   (third)
print(catalog[-1])   # Don Quixote   (last, without knowing how many there are)
print(catalog[-2])   # Hamlet        (second to last)
Element Positive index Negative index
"The Odyssey" 0 -3
"Hamlet" 1 -2
"Don Quixote" 2 -1

Accessing an index that does not exist (catalog[10]) raises an IndexError that stops the program. In module 7 you will learn to catch that error; for now, check first with len().

Slicing in depth

Slicing extracts a brand-new sublist with the syntax some_list[start:stop:step]. Golden rule: start is included, stop is not.

titles = ["The Odyssey", "Hamlet", "Don Quixote", "Faust"]

print(titles[0:2])   # ['The Odyssey', 'Hamlet']            indices 0 and 1
print(titles[1:])    # ['Hamlet', 'Don Quixote', 'Faust']   from 1 to the end
print(titles[:2])    # ['The Odyssey', 'Hamlet']            from the start up to 1
print(titles[:])     # full copy (we'll come back to this in mutability!)
print(titles[::2])   # ['The Odyssey', 'Don Quixote']       every other one
print(titles[::-1])  # reversed list, without touching the original
print(titles[-2:])   # ['Don Quixote', 'Faust']             the last two

Points worth internalizing:

  • Omitting start means "from the beginning"; omitting stop, "to the end".
  • A slice never errors for going past the end: titles[1:100] returns whatever is there, no IndexError.
  • A negative step walks backwards; [::-1] is the classic idiom for reversing.
  • The result is always a new list: modifying it does not affect the original.

List methods: adding, removing and searching

Methods are called with the syntax some_list.method(...). Most of them modify the list in place and return None — a classic source of confusion.

Method What it does Returns
append(x) Adds x at the end None
insert(i, x) Inserts x at position i, shifting the rest None
extend(other) Adds all the elements of another iterable at the end None
remove(x) Removes the first occurrence of x (error if absent) None
pop(i) Removes and returns the element at i (by default, the last one) the element
index(x) Position of the first occurrence of x (error if absent) int
count(x) How many times x appears int

Let's see them in action with the arrival of "Faust" at Papyrus (with stock 0 for now: it is sold out at the distributor):

catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]
stocks = [4, 6, 8]

# Adding "Faust": you have to touch ALL THREE lists, and in the same order
catalog.append("Faust")
prices.append(21.00)
stocks.append(0)

print(catalog.index("Hamlet"))   # 1 → useful for finding its price: prices[1]
print(catalog.count("Faust"))    # 1

# append vs extend: the difference matters
new_arrivals = ["The Iliad", "Macbeth"]
copy = catalog[:]
copy.append(new_arrivals)   # ['The Odyssey', ..., 'Faust', ['The Iliad', 'Macbeth']]  a list inside a list!
copy = catalog[:]
copy.extend(new_arrivals)   # ['The Odyssey', ..., 'Faust', 'The Iliad', 'Macbeth']    individual elements

Notice the maintenance cost: adding one book requires three coordinated append() calls, and one forgotten remove() on any of the lists knocks prices and stock out of sync forever. This pain is deliberate: it is the hook we will resolve in 04-03.

The order queue

Lists also work as a queue: orders come in at the end (append) and are served from the front (pop(0)):

order_queue = []
order_queue.append("Luis: Don Quixote")
order_queue.append("Marta: Hamlet")
order_queue.append("Luis: Faust")

while order_queue:                     # truthiness: an empty list is False
    order = order_queue.pop(0)         # removes and returns the first one
    print(f"Serving -> {order}")

It works, but pop(0) forces Python to shift every remaining element one position. For long queues there is a structure designed for exactly this, collections.deque, which you will see in 04-06.

Sorting: sort() vs sorted() and reverse()

This distinction sums up the philosophy of list methods:

some_list.sort() sorted(some_list)
What does it modify? The original list, in place Nothing: it creates a new list
What does it return? None The sorted list
What does it work on? Lists only Any iterable (tuples, strings...)
Typical use When you no longer need the original order When you want to keep the original
prices = [12.50, 9.95, 15.90, 21.00]

cheapest_first = sorted(prices)          # [9.95, 12.5, 15.9, 21.0]; prices untouched
priciest_first = sorted(prices, reverse=True)

prices.sort()                            # now for real: prices ends up sorted
result = prices.sort()                   # careful! result is None

catalog = ["The Odyssey", "Hamlet", "Don Quixote", "Faust"]
catalog.reverse()                        # reverses in place (returns None)
by_length = sorted(catalog, key=len)     # the key= from lambdas (03-03) lives here too

Watch out with parallel lists: if Ana runs catalog.sort(), the titles get reordered but prices and stocks do not, and disaster is guaranteed. With zip() (03-02) you can sort everything together, but that is yet another juggling act that dictionaries will make unnecessary.

Mutability: aliases vs copies

Here is the most important (and treacherous) consequence of lists being mutable. Assigning a list to another variable does not copy it: it creates an alias, a second name for the same list.

stocks = [4, 6, 8]
inventory = stocks           # ALIAS: both variables point at THE SAME list

inventory.append(0)
print(stocks)                # [4, 6, 8, 0]  ← stocks "changed" too!
print(stocks is inventory)   # True: they are the same object
graph LR
    stocks --> L["[4, 6, 8, 0]"]
    inventory --> L

To get an independent list you have to copy explicitly:

copy1 = stocks.copy()   # the copy() method
copy2 = stocks[:]       # full slice: equivalent idiom
copy3 = list(stocks)    # constructor: also copies

copy1.append(99)
print(stocks)           # unchanged: the copy is a different list

This also explains the mutable default argument trap you saw in 03-02: if a function receives a list and modifies it, the caller sees the change, because both share the same object.

One nuance to file away: copy() and [:] make a shallow copy. If the list contains other lists inside, the outer list is copied but the inner ones remain shared. For deep copies there is copy.deepcopy(); knowing it exists is enough for now.

Nested lists

A list can contain lists. It is a primitive way to group each book's record:

records = [
    ["The Odyssey", 12.50, 4],
    ["Hamlet", 9.95, 6],
    ["Don Quixote", 15.90, 8],
]

print(records[1])       # ['Hamlet', 9.95, 6]  → the full record
print(records[1][1])    # 9.95                 → first index: row; second: column
records[2][2] -= 1      # Luis buys a Don Quixote: the stock drops to 7

for title, price, stock in records:   # unpacking in the for, just like with zip()
    print(f"{title:<12} {price:>6.2f} EUR  ({stock} units)")

That is already progress: each book travels together and a single append(["Faust", 21.00, 0]) adds the whole thing. But record[1] and record[2] are mute indices: nothing says that 1 is the price. The next lesson (tuples) and above all 04-03 (dictionaries) will polish this idea.

Membership: in and not in

The in / not in operators check whether a value is in the list, with no loops in sight:

catalog = ["The Odyssey", "Hamlet", "Don Quixote", "Faust"]

if "Faust" in catalog:
    print("We have it on file (though perhaps out of stock).")

if "Dracula" not in catalog:
    print("We'd have to order it from the distributor.")

It is an exact comparison: "faust" in catalog returns False because of the capitalization. That is why find_book() in papyrus_utils.py compares in lowercase; in 04-05 we will go even further by normalizing text.

Common Mistakes and Tips

  • Storing the result of sort(), append() or reverse(): some_list = some_list.sort() leaves some_list holding None. These methods modify in place and return None; do not reassign.
  • Confusing an alias with a copy: b = a does not copy. If you later mutate b and a "changes on its own", that is the symptom. Use a.copy() or a[:].
  • remove() and index() with values that don't exist raise a ValueError. Guard with if x in some_list: before calling (graceful error handling arrives in module 7).
  • Modifying a list while looping over it with for causes elements to be skipped. Loop over a copy (for x in some_list[:]) or build a new list with a comprehension.
  • append(other_list) when you meant extend(other_list): the first one inserts the whole list as a single nested element.
  • Tip: when torn between mutating and creating, ask yourself whether anyone else uses that list. If you received it as an argument, creating a new one (sorted, slicing, a comprehension) is almost always safer.

Exercises

  1. Synchronized add and remove. Starting from Papyrus's three parallel lists, write a function add_book(catalog, prices, stocks, title, price, stock) that appends the book to all three lists, and a remove_book(catalog, prices, stocks, title) that removes it from all three using index() and pop(). Try adding "Faust" (21.00, 0) and removing "Hamlet".
  2. Slices of the catalog. With catalog = ["The Odyssey", "Hamlet", "Don Quixote", "Faust"], obtain using slicing only: (a) the first two titles, (b) the last two, (c) the reversed catalog, (d) an independent copy that you can sort alphabetically without touching the original.
  3. Queue with member priority. Simulate the order queue as a list of strings "name: title". Orders from Luis (a member) should jump the queue: insert them with insert(0, ...) instead of append(). Serve the queue with while and pop(0), showing the final order.

Solutions

# Exercise 1
def add_book(catalog, prices, stocks, title, price, stock):
    """Appends a book to the three parallel lists (mutating them)."""
    catalog.append(title)
    prices.append(price)
    stocks.append(stock)

def remove_book(catalog, prices, stocks, title):
    """Removes a book from the three lists; returns its price and stock."""
    if title not in catalog:
        return None                    # sentinel, as in 03-02
    i = catalog.index(title)           # same row in all three lists
    catalog.pop(i)
    return (catalog, prices.pop(i), stocks.pop(i))[1:]  # (price, stock)

catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]
stocks = [4, 6, 8]
add_book(catalog, prices, stocks, "Faust", 21.00, 0)
print(remove_book(catalog, prices, stocks, "Hamlet"))   # (9.95, 6)
print(catalog)   # ['The Odyssey', 'Don Quixote', 'Faust']

Notice that the functions mutate the lists they receive: the caller sees the changes because they share the objects. And that every operation touches three structures — the definitive argument for 04-03.

# Exercise 2
catalog = ["The Odyssey", "Hamlet", "Don Quixote", "Faust"]
print(catalog[:2])         # (a) ['The Odyssey', 'Hamlet']
print(catalog[-2:])        # (b) ['Don Quixote', 'Faust']
print(catalog[::-1])       # (c) reversed
copy = catalog[:]          # (d) independent copy
copy.sort()
print(copy)                # sorted
print(catalog)             # the original, untouched
# Exercise 3
queue = []
def enqueue(queue, customer, title, member=False):
    order = f"{customer}: {title}"
    if member:
        queue.insert(0, order)   # members jump to the front
    else:
        queue.append(order)

enqueue(queue, "Marta", "Hamlet")
enqueue(queue, "Luis", "Don Quixote", member=True)
enqueue(queue, "Pau", "The Odyssey")

while queue:
    print("Serving ->", queue.pop(0))
# Luis: Don Quixote / Marta: Hamlet / Pau: The Odyssey

Tip: insert(0, ...) and pop(0) shift the whole list; for Papyrus it is irrelevant, but remember it when you meet deque in 04-06.

Conclusion

Lists are Python's Swiss Army knife: you know how to create them, index them (also from the end), slice them with [start:stop:step], and handle their methods while distinguishing the ones that mutate in place (append, sort, remove...) from the functions that create new lists (sorted, slicing). Above all, you now understand mutability: b = a creates an alias, not a copy, and that detail explains behaviors that until today looked like black magic. You have also seen their limits at Papyrus: parallel lists demand synchronization discipline, and nested lists use mute indices. The next step is their immutable sibling, the tuple: the perfect structure for records that must not change — and the one that finally settles the promise of multiple return values from functions that we left pending in 03-02.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved