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
- Creating lists
- Positive and negative indexing
- Slicing in depth
- List methods: adding, removing and searching
- Sorting:
sort()vssorted()andreverse() - Mutability: aliases vs copies
- Nested lists
- Membership:
inandnot 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 twoPoints worth internalizing:
- Omitting
startmeans "from the beginning"; omittingstop, "to the end". - A slice never errors for going past the end:
titles[1:100]returns whatever is there, noIndexError. - A negative
stepwalks 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 elementsNotice 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 tooWatch 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 objectgraph 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 listThis 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()orreverse():some_list = some_list.sort()leavessome_listholdingNone. These methods modify in place and returnNone; do not reassign. - Confusing an alias with a copy:
b = adoes not copy. If you later mutatebanda"changes on its own", that is the symptom. Usea.copy()ora[:]. remove()andindex()with values that don't exist raise aValueError. Guard withif x in some_list:before calling (graceful error handling arrives in module 7).- Modifying a list while looping over it with
forcauses 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 meantextend(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
- 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 aremove_book(catalog, prices, stocks, title)that removes it from all three usingindex()andpop(). Try adding "Faust" (21.00, 0) and removing "Hamlet". - 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. - 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 withinsert(0, ...)instead ofappend(). Serve the queue withwhileandpop(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 OdysseyTip: 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
