16 min read

> "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)

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.namedtuple and typing.NamedTuple in 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 — use my_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 wanted

b = [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 a ValueError if the element isn't found. Always check with in first, 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() returns None, so result = my_list.sort() sets result to None, 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, use itertools.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 for loop. Readability is more important than cleverness. A good rule of thumb: one for, one optional if, 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 write x, 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 = a copies the data. After this section, you'll understand that b = a makes b point to the same object — and for mutable objects like lists, that means changes through b affect a too.

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. a and b are 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() or my_list[:] is sufficient. Only reach for copy.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 datetime for 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.