> "Bad programmers worry about the code. Good programmers worry about data structures and their relationships."
Learning Objectives
- Create, access, and modify lists using indexing and slicing
- Use list methods to add, remove, search, and sort elements
- Write list comprehensions for concise data transformations
- Understand the difference between mutable lists and immutable tuples
- Avoid common aliasing and copying pitfalls with mutable objects
- Use nested lists to represent 2D data (tables, grids)
In This Chapter
- Chapter Overview
- 8.1 Why Lists?
- 8.2 Creating and Accessing Lists
- 8.3 Lists Are Mutable
- 8.4 Essential List Methods
- 8.5 Iterating Over Lists
- 8.6 List Comprehensions
- 8.7 Tuples: Immutable Sequences
- 8.8 Aliasing and Copying
- 8.9 Nested Lists
- 8.10 Common Patterns
- 8.11 Project Checkpoint: TaskFlow v0.7
- Chapter Summary
- What's Next
Chapter 8: Lists and Tuples: Working with Sequences
"Bad programmers worry about the code. Good programmers worry about data structures and their relationships." — Linus Torvalds
Chapter Overview
Up until now, every variable you've created has held a single value — one number, one string, one Boolean. That's fine for small problems, but the real world doesn't work that way. A grade book has many scores. An inventory has many items. A playlist has many songs. You need a way to store collections of related data under one name, and then do useful things with them.
That's what lists and tuples are for. They're Python's workhorse sequence types, and you'll use them in nearly every program you write from this point forward. Lists are flexible: you can add items, remove items, sort them, and rearrange them. Tuples are rigid: once you create one, it never changes. That rigidity isn't a limitation — it's a feature. Knowing when to use each one is part of becoming a thoughtful programmer.
This chapter also introduces one of the most common sources of bugs in Python: aliasing. When two variable names point to the same list, modifying one appears to "magically" change the other. Understanding why this happens — and how to prevent it when you don't want it — is a threshold concept that separates beginners from intermediate programmers.
In this chapter, you will learn to:
- Store collections of data in lists and access individual elements
- Modify lists using methods like append(), remove(), sort(), and more
- Write concise list comprehensions to transform data
- Choose between lists (mutable) and tuples (immutable) for a given problem
- Recognize and avoid aliasing bugs with mutable objects
- Represent 2D data with nested lists
🏃 Fast Track: If you're already comfortable with basic list creation and indexing, skim sections 8.1–8.2 and start at section 8.3 (Lists Are Mutable). Don't skip section 8.8 (Aliasing and Copying) — even experienced programmers get bitten by this.
🔬 Deep Dive: After this chapter, explore
collections.namedtupleandtyping.NamedTuplein the Python docs for more structured tuple usage. We'll revisit these ideas when we build our own classes in Chapter 14.
8.1 Why Lists?
Let's say you're building the grade calculator from earlier chapters and you need to store five exam scores. Without lists, you'd do something like this:
score1 = 92
score2 = 85
score3 = 78
score4 = 95
score5 = 88
That "works," but it's brittle. What if there are 30 students? 300? You'd need 300 variable names, and any operation — finding the average, the highest score, sorting — would require writing out every variable by hand.
Lists solve this problem:
scores = [92, 85, 78, 95, 88]
average = sum(scores) / len(scores)
print(f"Average: {average}") # Average: 87.6
Five scores or five thousand — the code is the same. That's the power of collections.
When You Need a List
You need a list whenever you have:
- Multiple values of the same kind: exam scores, names, temperatures, file paths
- Data you need to process in order: steps in a recipe, moves in a game, lines in a file
- Data that changes over time: a shopping cart, an inventory, a to-do list
Elena Vasquez, our nonprofit data analyst, deals with this daily. Her weekly report processes donation records — each one has a donor name, amount, and date. Without lists, she'd be copying and pasting data by hand. With lists, she can write a script that processes any number of records automatically.
In the text adventure game, the player's inventory is a perfect use case for a list. Items get added when the player picks them up, removed when they're used, and checked whenever the player tries to do something ("You need the rusty key to open this door").
8.2 Creating and Accessing Lists
Creating Lists
There are several ways to create a list:
# Literal syntax — most common
fruits = ["apple", "banana", "cherry"]
# Empty list
empty = []
# Mixed types (legal but usually not a great idea)
mixed = [42, "hello", True, 3.14]
# From another iterable
letters = list("hello") # ['h', 'e', 'l', 'l', 'o']
numbers = list(range(1, 6)) # [1, 2, 3, 4, 5]
💡 Intuition: If you worked through Chapter 7 on strings, list syntax will feel familiar. Strings are sequences of characters; lists are sequences of anything. Most of the indexing and slicing rules you learned for strings work identically for lists.
Indexing
Lists use zero-based indexing, just like strings:
fruits = ["apple", "banana", "cherry", "date", "elderberry"]
print(fruits[0]) # apple (first element)
print(fruits[2]) # cherry (third element)
print(fruits[-1]) # elderberry (last element)
print(fruits[-2]) # date (second to last)
Trying to access an index that doesn't exist raises an IndexError:
print(fruits[10]) # IndexError: list index out of range
⚠️ Pitfall: Off-by-one errors are the most common list bug. A list of 5 elements has valid indices 0 through 4, not 1 through 5. If you find yourself writing
my_list[len(my_list)], you're one past the end — usemy_list[len(my_list) - 1]or, better,my_list[-1].
Slicing
Slicing extracts a sub-list. The syntax is list[start:stop:step], where stop is exclusive:
numbers = [10, 20, 30, 40, 50, 60, 70, 80]
print(numbers[1:4]) # [20, 30, 40] — index 1 up to (not including) 4
print(numbers[:3]) # [10, 20, 30] — from the beginning
print(numbers[5:]) # [60, 70, 80] — to the end
print(numbers[::2]) # [10, 30, 50, 70] — every other element
print(numbers[::-1]) # [80, 70, 60, 50, 40, 30, 20, 10] — reversed
🔗 Connection to Chapter 7: If you already know string slicing, you already know list slicing. The syntax is identical:
sequence[start:stop:step]. The only difference is that a string slice gives you a string, while a list slice gives you a list.
The len() Function
Use len() to get the number of elements:
scores = [92, 85, 78, 95, 88]
print(len(scores)) # 5
The in Operator
Check membership with in:
inventory = ["sword", "shield", "potion"]
if "potion" in inventory:
print("You drink the potion. Health restored!")
# Output: You drink the potion. Health restored!
if "key" not in inventory:
print("You don't have a key.")
# Output: You don't have a key.
This is how the text adventure checks whether the player has a required item — clean, readable, and fast enough for any game inventory.
8.3 Lists Are Mutable
Here's the big difference between lists and strings. Strings are immutable — you learned in Chapter 7 that you can't change a character in place. Lists are mutable — you can change elements, add new ones, remove existing ones, and rearrange the order, all without creating a new list.
# Changing an element in place
colors = ["red", "green", "blue"]
colors[1] = "yellow"
print(colors) # ['red', 'yellow', 'blue']
Compare this to strings:
name = "hello"
name[0] = "H" # TypeError: 'str' object does not support item assignment
You can also replace a slice:
numbers = [1, 2, 3, 4, 5]
numbers[1:3] = [20, 30]
print(numbers) # [1, 20, 30, 4, 5]
# You can even replace with a different number of elements
numbers[1:3] = [200]
print(numbers) # [1, 200, 4, 5]
💡 Intuition: Mutability is about whether an object can be changed after creation. Lists: yes. Strings: no. Tuples: no. This matters enormously for understanding aliasing, which we'll cover in section 8.8. For now, just remember: if you assign a list to a new variable, both variables point to the same list. Changes through one name are visible through the other. (This will trip you up at least once. That's normal.)
8.4 Essential List Methods
Python lists come with a rich set of built-in methods. Here are the ones you'll use constantly.
Adding Elements
tasks = ["buy groceries", "do laundry"]
# append() — add one element to the end
tasks.append("call dentist")
print(tasks) # ['buy groceries', 'do laundry', 'call dentist']
# insert() — add at a specific position
tasks.insert(0, "wake up")
print(tasks) # ['wake up', 'buy groceries', 'do laundry', 'call dentist']
# extend() — add all elements from another iterable
more_tasks = ["study Python", "exercise"]
tasks.extend(more_tasks)
print(tasks)
# ['wake up', 'buy groceries', 'do laundry', 'call dentist', 'study Python', 'exercise']
⚠️ Pitfall:
append()vs.extend()— a classic beginner mistake: ```python a = [1, 2, 3] a.append([4, 5]) # Adds the LIST as a single element print(a) # [1, 2, 3, [4, 5]] — probably not what you wantedb = [1, 2, 3] b.extend([4, 5]) # Adds each element individually print(b) # [1, 2, 3, 4, 5] — probably what you wanted ```
Removing Elements
inventory = ["sword", "shield", "potion", "shield", "map"]
# remove() — remove the FIRST occurrence by value
inventory.remove("shield")
print(inventory) # ['sword', 'potion', 'shield', 'map']
# pop() — remove by index and return the removed element
last_item = inventory.pop() # removes and returns 'map'
print(last_item) # map
second_item = inventory.pop(1) # removes and returns 'potion'
print(inventory) # ['sword', 'shield']
# clear() — remove everything
inventory.clear()
print(inventory) # []
⚠️ Pitfall:
remove()raises aValueErrorif the element isn't found. Always check withinfirst, or use a try/except block (Chapter 11):python items = ["a", "b", "c"] items.remove("z") # ValueError: list.remove(x): x not in list
Sorting
scores = [78, 95, 88, 62, 91]
# sort() — sorts IN PLACE, returns None
scores.sort()
print(scores) # [62, 78, 88, 91, 95]
# Descending order
scores.sort(reverse=True)
print(scores) # [95, 91, 88, 78, 62]
# sorted() — returns a NEW sorted list, original unchanged
original = [78, 95, 88, 62, 91]
ranked = sorted(original)
print(ranked) # [62, 78, 88, 91, 95]
print(original) # [78, 95, 88, 62, 91] — unchanged!
✅ Best Practice: Use
sorted()when you need to keep the original list intact. Use.sort()when you want to sort in place and don't need the original order. The distinction matters —.sort()returnsNone, soresult = my_list.sort()setsresulttoNone, not the sorted list. This is a very common bug.
Searching and Counting
names = ["alice", "bob", "charlie", "bob", "diana"]
# index() — find position of first occurrence
pos = names.index("charlie")
print(pos) # 2
# count() — count occurrences
bob_count = names.count("bob")
print(bob_count) # 2
# reverse() — reverse in place
names.reverse()
print(names) # ['diana', 'bob', 'charlie', 'bob', 'alice']
Quick Reference Table
| Method | What It Does | Returns | Modifies List? |
|---|---|---|---|
append(x) |
Add x to end |
None |
Yes |
extend(iterable) |
Add all items from iterable | None |
Yes |
insert(i, x) |
Insert x at position i |
None |
Yes |
remove(x) |
Remove first occurrence of x |
None |
Yes |
pop(i) |
Remove and return item at i (default: last) |
The removed item | Yes |
sort() |
Sort in place | None |
Yes |
reverse() |
Reverse in place | None |
Yes |
index(x) |
Find first index of x |
Index (int) | No |
count(x) |
Count occurrences of x |
Count (int) | No |
clear() |
Remove all items | None |
Yes |
copy() |
Shallow copy | New list | No |
Notice the pattern: methods that change the list return None. This is a deliberate Python design choice. It prevents you from accidentally chaining operations that modify the list.
8.5 Iterating Over Lists
Basic For Loop
The most natural way to process a list is with a for loop:
scores = [92, 85, 78, 95, 88]
total = 0
for score in scores:
total += score
average = total / len(scores)
print(f"Average: {average}") # Average: 87.6
Using enumerate() When You Need the Index
Sometimes you need both the element and its position:
fruits = ["apple", "banana", "cherry"]
# Don't do this (clunky):
for i in range(len(fruits)):
print(f"{i + 1}. {fruits[i]}")
# Do this instead:
for i, fruit in enumerate(fruits, start=1):
print(f"{i}. {fruit}")
Both produce:
1. apple
2. banana
3. cherry
The enumerate() version is more Pythonic, less error-prone, and more readable. The optional start parameter lets you begin numbering from any value.
Using zip() to Iterate Over Two Lists
When you have parallel lists — lists where element i in each list is related — zip() pairs them up:
students = ["Alice", "Bob", "Charlie"]
scores = [92, 85, 78]
for student, score in zip(students, scores):
print(f"{student}: {score}")
Output:
Alice: 92
Bob: 85
Charlie: 78
⚠️ Pitfall: If the lists have different lengths,
zip()stops at the shorter one without warning. If that's a problem, useitertools.zip_longest()from the standard library.
🔄 Check Your Understanding #1
What does this code print?
words = ["Python", "is", "fun"]
for i, word in enumerate(words):
print(f"{i}: {word}")
Answer
0: Python
1: is
2: fun
`enumerate()` with no `start` argument begins at 0. Each iteration unpacks the index and the element.
8.6 List Comprehensions
List comprehensions are a compact syntax for creating new lists by transforming or filtering existing ones. They're one of Python's signature features and a major reason Python code can be so concise.
Basic Syntax
# Without comprehension
squares = []
for x in range(1, 6):
squares.append(x ** 2)
print(squares) # [1, 4, 9, 16, 25]
# With comprehension — same result, one line
squares = [x ** 2 for x in range(1, 6)]
print(squares) # [1, 4, 9, 16, 25]
The general pattern is:
[expression for variable in iterable]
Filtering with a Condition
Add an if clause to filter:
scores = [92, 85, 45, 78, 95, 32, 88]
# Only passing scores (>= 60)
passing = [s for s in scores if s >= 60]
print(passing) # [92, 85, 78, 95, 88]
Transforming with a Condition
# Celsius to Fahrenheit for temperatures above freezing
temps_c = [-5, 10, 0, 25, -12, 30]
warm_f = [c * 9/5 + 32 for c in temps_c if c > 0]
print(warm_f) # [50.0, 77.0, 86.0]
When To Use Comprehensions (and When Not To)
Comprehensions are great for simple transformations and filters. They become hard to read when they get complex:
# Good — clear and concise
names = ["alice", "bob", "charlie"]
upper_names = [name.upper() for name in names]
# Pushing it — still OK but getting long
passing_grades = [f"{name}: {grade}" for name, grade in zip(names, [92, 55, 78]) if grade >= 60]
# Too much — just use a regular loop
# Don't write comprehensions that wrap across multiple lines with nested conditions
✅ Best Practice: If a comprehension doesn't fit comfortably on one line, or if someone reading it has to puzzle over what it does, use a regular
forloop. Readability is more important than cleverness. A good rule of thumb: onefor, one optionalif, and a simple expression. Anything more complex deserves a loop.
Elena's Donation Report
Elena needs to extract donation amounts over $100 from her records:
donations = [
("Alice", 50.00),
("Bob", 250.00),
("Charlie", 75.00),
("Diana", 500.00),
("Eve", 30.00),
]
major_donations = [amount for name, amount in donations if amount > 100]
print(major_donations) # [250.0, 500.0]
print(f"Total major donations: ${sum(major_donations):,.2f}")
# Total major donations: $750.00
8.7 Tuples: Immutable Sequences
A tuple is like a list that can't be changed after creation. You create them with parentheses (or often just commas):
# Creating tuples
point = (3, 7)
rgb = (255, 128, 0)
single = (42,) # Note the trailing comma — without it, (42) is just 42
empty = ()
no_parens = 10, 20, 30 # Parentheses are optional (but recommended for clarity)
Tuples support indexing and slicing just like lists:
coordinates = (10, 20, 30)
print(coordinates[0]) # 10
print(coordinates[-1]) # 30
print(coordinates[1:]) # (20, 30)
But you can't modify them:
coordinates[0] = 99 # TypeError: 'tuple' object does not support item assignment
When to Use Tuples Instead of Lists
| Use a list when... | Use a tuple when... |
|---|---|
| Elements will be added/removed | Data shouldn't change after creation |
| Order might change (sorting) | Representing a fixed-structure record |
| You're building up a collection | Returning multiple values from a function |
| All elements are the same kind | Elements have different meanings by position |
Common tuple use cases:
# Returning multiple values from a function
def min_max(numbers):
return min(numbers), max(numbers)
lowest, highest = min_max([23, 45, 12, 67, 34])
print(f"Range: {lowest} to {highest}") # Range: 12 to 67
# Database-style records
student = ("Alice", 20, "Computer Science", 3.8)
# Dictionary keys (lists can't be dictionary keys)
locations = {
(40.7128, -74.0060): "New York",
(51.5074, -0.1278): "London",
}
Tuple Unpacking
One of the most elegant features in Python is tuple unpacking — assigning each element of a tuple to a separate variable in one step:
# Basic unpacking
name, age, major = ("Alice", 20, "CS")
print(name) # Alice
print(age) # 20
print(major) # CS
# Swap two variables — no temp variable needed!
a, b = 10, 20
a, b = b, a
print(a, b) # 20 10
# Star unpacking (Python 3+)
first, *rest = [1, 2, 3, 4, 5]
print(first) # 1
print(rest) # [2, 3, 4, 5]
head, *middle, tail = [10, 20, 30, 40, 50]
print(head) # 10
print(middle) # [20, 30, 40]
print(tail) # 50
🔗 Connection to Chapter 6: You've been using tuple unpacking without realizing it. When a function returns multiple values with
return a, b, Python packs them into a tuple. When you writex, y = some_function(), you're unpacking that tuple. Now you know the mechanism behind it.
A Preview: Named Tuples
Regular tuples access elements by position, which can be unclear:
student = ("Alice", 20, "CS", 3.8)
print(student[3]) # 3.8 — but what does index 3 mean?
Named tuples let you access by name:
from collections import namedtuple
Student = namedtuple("Student", ["name", "age", "major", "gpa"])
alice = Student("Alice", 20, "CS", 3.8)
print(alice.gpa) # 3.8 — much clearer!
print(alice.name) # Alice
We'll see named tuples again in Chapter 14 when we build our own classes. For now, just know they exist.
🔄 Check Your Understanding #2
What's wrong with this code?
person = ("Alice", 30, "Engineer")
person[1] = 31
Answer
Tuples are immutable — you can't change elements after creation. `person[1] = 31` raises a `TypeError`. To "update" a tuple, you'd create a new one:person = (person[0], 31, person[2])
# or
person = ("Alice", 31, "Engineer")
8.8 Aliasing and Copying
🚪 Threshold Concept: Mutability and Aliasing
This is one of those ideas that changes how you think about programming. Before this section, most students assume that
b = acopies the data. After this section, you'll understand thatb = amakesbpoint to the same object — and for mutable objects like lists, that means changes throughbaffectatoo.
The Problem
Look at this code carefully and predict what it prints:
a = [1, 2, 3]
b = a
b.append(4)
print(a)
If you guessed [1, 2, 3], you're thinking like most beginners — and you're wrong. The answer is:
print(a) # [1, 2, 3, 4]
Wait, what? We appended to b, not a. Why did a change?
Understanding the Memory Model
When you write b = a, Python does NOT copy the list. It makes b point to the exact same list object in memory. Here's what the memory looks like:
BEFORE b.append(4):
a ──────┐
▼
┌─────────────────┐
│ [1, 2, 3] │ ← one list object in memory
└─────────────────┘
▲
b ──────┘
AFTER b.append(4):
a ──────┐
▼
┌─────────────────┐
│ [1, 2, 3, 4] │ ← still ONE list object — both names see the change
└─────────────────┘
▲
b ──────┘
This is aliasing: two names for the same object. It's not a bug — it's how Python works. And with immutable objects (strings, integers, tuples), it doesn't matter, because you can't change the object anyway. It only matters with mutable objects like lists and dictionaries.
You can verify aliasing with id() and is:
a = [1, 2, 3]
b = a
print(id(a) == id(b)) # True — same object in memory
print(a is b) # True — same object
🔗 Connection to Chapter 3: Remember when we said variables are "name tags attached to objects, not boxes that hold values"? This is where that mental model pays off.
aandbare two name tags attached to the same object. Pull on either tag, you reach the same list.
🧩 Productive Struggle: What Does This Code Print?
Before reading on, work through this code by hand. Draw the memory diagram. Then check your answer.
x = [10, 20, 30]
y = x
y[0] = 99
z = x[:]
z[1] = 77
print(x)
print(y)
print(z)
Answer
[99, 20, 30]
[99, 20, 30]
[77, 20, 30] — wait, this is wrong. Let me trace again...
Actually, let's trace carefully:
1. `x = [10, 20, 30]` — creates a list; `x` points to it
2. `y = x` — `y` points to the *same* list (alias)
3. `y[0] = 99` — modifies the shared list to `[99, 20, 30]`; `x` sees this too
4. `z = x[:]` — creates a *new copy* of the list `[99, 20, 30]`; `z` points to the copy
5. `z[1] = 77` — modifies `z`'s copy to `[99, 77, 30]`; `x` is unaffected
Output:
[99, 20, 30]
[99, 20, 30]
[99, 77, 30]
Key insight: `x[:]` (slice of the entire list) creates a **copy**. Changes to the copy don't affect the original.
How to Actually Copy a List
When you want an independent copy, you have three options:
original = [1, 2, 3]
# Option 1: Slice the whole list
copy1 = original[:]
# Option 2: list() constructor
copy2 = list(original)
# Option 3: .copy() method
copy3 = original.copy()
# All three create independent copies
copy1.append(4)
print(original) # [1, 2, 3] — unaffected
print(copy1) # [1, 2, 3, 4]
Shallow vs. Deep Copy
There's a catch. All three methods above create shallow copies — they copy the list itself but not the objects inside it. For nested lists, this matters:
matrix = [[1, 2], [3, 4]]
shallow = matrix.copy()
shallow[0][0] = 99
print(matrix) # [[99, 2], [3, 4]] — the inner list changed!
Why? The shallow copy created a new outer list, but both matrix[0] and shallow[0] still point to the same inner list.
For a fully independent copy of nested structures, use deepcopy():
import copy
matrix = [[1, 2], [3, 4]]
deep = copy.deepcopy(matrix)
deep[0][0] = 99
print(matrix) # [[1, 2], [3, 4]] — unaffected!
print(deep) # [[99, 2], [3, 4]]
✅ Best Practice: For simple lists (no nested lists),
my_list.copy()ormy_list[:]is sufficient. Only reach forcopy.deepcopy()when you have nested mutable structures. Deep copying is slower, so don't use it unless you need it.
🐛 Debugging Walkthrough: "I Changed List B But List A Changed Too!"
Symptom: You have two "separate" lists, but modifying one mysteriously modifies the other.
The Code:
def add_default_tasks(task_list):
task_list.append("check email")
task_list.append("standup meeting")
return task_list
monday = ["write report"]
tuesday = monday # BUG: this creates an alias, not a copy!
monday_tasks = add_default_tasks(monday)
tuesday_tasks = add_default_tasks(tuesday)
print(f"Monday: {monday_tasks}")
print(f"Tuesday: {tuesday_tasks}")
Expected Output:
Monday: ['write report', 'check email', 'standup meeting']
Tuesday: ['write report', 'check email', 'standup meeting']
Actual Output:
Monday: ['write report', 'check email', 'standup meeting', 'check email', 'standup meeting']
Tuesday: ['write report', 'check email', 'standup meeting', 'check email', 'standup meeting']
Diagnosis: tuesday = monday made both names point to the same list. Every append() call — from both add_default_tasks(monday) and add_default_tasks(tuesday) — modified that one shared list.
Fix:
tuesday = monday.copy() # Create an independent copy
Prevention Checklist:
1. When you write b = a where a is a list, ask yourself: do I want an alias or a copy?
2. If you want a copy, use .copy(), list(), or [:]
3. If the list contains other lists or mutable objects, consider copy.deepcopy()
4. When a function modifies a list, remember that the caller's list is affected too (because lists are passed by reference — more on this in the next Spaced Review)
Spaced Review: Functions and Lists (Chapter 6)
When you pass a list to a function, the function gets a reference to the same list, not a copy. This is the same aliasing behavior:
def remove_negatives(numbers):
"""Remove all negative numbers from the list. Modifies in place."""
i = 0
while i < len(numbers):
if numbers[i] < 0:
numbers.pop(i)
else:
i += 1
data = [4, -2, 7, -1, 3]
remove_negatives(data)
print(data) # [4, 7, 3] — the original list was modified!
This can be useful (the function modifies the original) or dangerous (the caller didn't expect its data to change). If you want a function that leaves the original untouched, copy first:
def without_negatives(numbers):
"""Return a new list with negative numbers removed. Original unchanged."""
return [n for n in numbers if n >= 0]
data = [4, -2, 7, -1, 3]
clean = without_negatives(data)
print(data) # [4, -2, 7, -1, 3] — unchanged
print(clean) # [4, 7, 3]
✅ Best Practice: Make it clear in your function's docstring whether the function modifies the list in place or returns a new one. Better yet, make the function name indicate this:
remove_negatives()(sounds like it modifies in place) vs.without_negatives()(sounds like it returns something new).
8.9 Nested Lists
A list can contain other lists. This is how you represent 2D data — tables, grids, matrices:
# A 3x3 tic-tac-toe board
board = [
["X", "O", "X"],
["O", "X", "O"],
["O", "X", "X"],
]
# Access individual cells: board[row][col]
print(board[0][0]) # X (top-left)
print(board[1][1]) # X (center)
print(board[2][2]) # X (bottom-right)
Working with a Grade Table
Here's a practical example using the grade calculator scenario — a table where each row is a student's scores:
# Each row: [name, exam1, exam2, exam3]
gradebook = [
["Alice", 92, 85, 88],
["Bob", 78, 82, 90],
["Charlie", 95, 91, 87],
]
# Calculate each student's average
for row in gradebook:
name = row[0]
scores = row[1:]
avg = sum(scores) / len(scores)
print(f"{name}: {avg:.1f}")
Output:
Alice: 88.3
Bob: 83.3
Charlie: 91.0
Iterating Over a Grid
To process every cell in a 2D structure, use nested loops:
grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
for row in grid:
for cell in row:
print(f"{cell:3}", end="")
print() # newline after each row
Output:
1 2 3
4 5 6
7 8 9
Creating a Grid Dynamically
⚠️ Pitfall: Watch out for the "repeated reference" trap when creating grids:
# WRONG — all three rows are the SAME list!
bad_grid = [[0] * 3] * 3
bad_grid[0][0] = 99
print(bad_grid) # [[99, 0, 0], [99, 0, 0], [99, 0, 0]] — all rows changed!
# RIGHT — each row is an independent list
good_grid = [[0] * 3 for _ in range(3)]
good_grid[0][0] = 99
print(good_grid) # [[99, 0, 0], [0, 0, 0], [0, 0, 0]] — only first row changed
This is aliasing again! [[0] * 3] * 3 creates one inner list and puts three references to it in the outer list. The comprehension version creates three independent inner lists.
8.10 Common Patterns
These are patterns you'll use constantly when working with lists. Learn to recognize them by shape.
Finding the Minimum and Maximum
temperatures = [72, 68, 75, 80, 65, 77, 82]
# Built-in functions
print(min(temperatures)) # 65
print(max(temperatures)) # 82
# With the index
coldest_day = temperatures.index(min(temperatures))
print(f"Coldest on day {coldest_day + 1}") # Coldest on day 5
Filtering
scores = [92, 45, 78, 33, 88, 67, 95]
# Using a loop
passing = []
for s in scores:
if s >= 60:
passing.append(s)
# Using a comprehension (same result)
passing = [s for s in scores if s >= 60]
print(passing) # [92, 78, 88, 67, 95]
Accumulating into a New List
words = ["hello", "world", "python"]
lengths = [len(w) for w in words]
print(lengths) # [5, 5, 6]
Building Up a Result
# Running total (cumulative sum)
payments = [100, 200, 150, 300]
running_total = []
total = 0
for payment in payments:
total += payment
running_total.append(total)
print(running_total) # [100, 300, 450, 750]
Flattening a Nested List
nested = [[1, 2], [3, 4], [5, 6]]
flat = [item for sublist in nested for item in sublist]
print(flat) # [1, 2, 3, 4, 5, 6]
🔄 Check Your Understanding #3
Write a list comprehension that takes a list of names and returns only the names that start with a vowel, converted to uppercase.
names = ["alice", "bob", "eve", "ian", "carol", "uma"]
# Your comprehension here
Answer
names = ["alice", "bob", "eve", "ian", "carol", "uma"]
result = [name.upper() for name in names if name[0] in "aeiou"]
print(result) # ['ALICE', 'EVE', 'IAN', 'UMA']
This combines a transformation (`name.upper()`) with a filter (`if name[0] in "aeiou"`).
Spaced Review: Conditionals (Chapter 4)
Lists and conditionals work together constantly. Here's a pattern that combines them — categorizing list elements:
scores = [92, 45, 78, 33, 88, 67, 95, 51]
categories = {"A": [], "B": [], "C": [], "D": [], "F": []}
for score in scores:
if score >= 90:
categories["A"].append(score)
elif score >= 80:
categories["B"].append(score)
elif score >= 70:
categories["C"].append(score)
elif score >= 60:
categories["D"].append(score)
else:
categories["F"].append(score)
for grade, vals in categories.items():
print(f"{grade}: {vals}")
Output:
A: [92, 95]
B: [88]
C: [78]
D: [67]
F: [45, 33, 51]
8.11 Project Checkpoint: TaskFlow v0.7
Time to upgrade TaskFlow. In v0.6 (Chapter 7), you added keyword search and formatted display. Now we'll restructure how tasks are stored: instead of a flat list of strings, each task becomes a tuple of (name, priority, created_at). This lets us sort tasks by priority and perform bulk operations.
What's New in v0.7
- Tasks stored as a list of tuples:
(name, priority, created_at) - Sort by priority: display tasks in priority order
- Bulk operations: mark all as complete, delete completed tasks
- Uses
datetimefor timestamps (a preview of the standard library)
Here's the core of the upgrade — see code/project-checkpoint.py for the full implementation:
from datetime import datetime
# Each task: (name, priority, created_at)
# Priority: 1 = high, 2 = medium, 3 = low
tasks: list[tuple[str, int, str]] = []
def add_task(name: str, priority: int) -> None:
"""Add a new task as a tuple (name, priority, timestamp)."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
task = (name, priority, timestamp)
tasks.append(task)
print(f"Added: '{name}' (priority {priority})")
def list_tasks(sort_by_priority: bool = False) -> None:
"""Display all tasks, optionally sorted by priority."""
if not tasks:
print("No tasks yet.")
return
display = sorted(tasks, key=lambda t: t[1]) if sort_by_priority else tasks
priority_labels = {1: "HIGH", 2: "MED", 3: "LOW"}
print(f"\n{'#':<4} {'Task':<25} {'Priority':<10} {'Created':<16}")
print("-" * 55)
for i, (name, priority, created) in enumerate(display, start=1):
label = priority_labels.get(priority, "???")
print(f"{i:<4} {name:<25} {label:<10} {created:<16}")
print()
def delete_task(index: int) -> None:
"""Delete a task by its display number (1-based)."""
if 1 <= index <= len(tasks):
removed = tasks.pop(index - 1)
print(f"Deleted: '{removed[0]}'")
else:
print(f"Invalid task number. Choose 1-{len(tasks)}.")
def bulk_delete_by_priority(priority: int) -> int:
"""Delete all tasks with a given priority. Returns count deleted."""
before = len(tasks)
# Build a new list excluding tasks with the given priority
remaining = [t for t in tasks if t[1] != priority]
deleted_count = before - len(remaining)
tasks.clear()
tasks.extend(remaining)
return deleted_count
What you practiced: tuples as records, list of tuples, sorted() with a key function, tuple unpacking in enumerate(), list comprehension for filtering, the .clear() / .extend() pattern for in-place replacement.
📊 Progress Check: At this point, TaskFlow can add tasks with priorities, display them sorted, search by keyword (v0.6), and bulk-delete by priority. In Chapter 9, we'll switch from tuples to dictionaries for even more flexible task storage.
Chapter Summary
This chapter covered a lot of ground — lists and tuples are foundational data structures that you'll use in nearly every Python program. Here are the key takeaways:
- Lists are ordered, mutable sequences. Use them when you need to add, remove, or rearrange elements.
- Tuples are ordered, immutable sequences. Use them for fixed-structure records and returning multiple values from functions.
- Mutability means an object can be changed after creation. Lists are mutable; tuples and strings are not.
- Aliasing occurs when two variables point to the same mutable object. Use
.copy()or slicing to create independent copies when needed. - Shallow copies duplicate the outer container but share references to inner objects. Use
copy.deepcopy()for fully independent copies of nested structures. - List comprehensions are a concise way to create new lists from existing ones, but readability always wins over cleverness.
- Nested lists let you represent 2D data like tables and grids.
- Functions that receive lists can modify the caller's data — document whether your function modifies in place or returns a new list.
In Chapter 9, we'll add dictionaries and sets to your toolkit — data structures that organize data by keys rather than by position, enabling O(1) lookups that transform what's practical.
What's Next
Chapter 9 introduces dictionaries — mapping keys to values — and sets for membership testing and deduplication. You'll also learn how to choose the right data structure (list vs. dict vs. set vs. tuple) for a given problem. TaskFlow will upgrade from tuples to dictionaries, giving each task named fields instead of positional access.