Iteration · about 40 min

Comprehensions

Why this matters

A huge share of everyday code is "make a new collection from an old one": square these numbers, keep only the valid rows, map names to lengths, collect the distinct domains in a list of email addresses. Python has a compact syntax for exactly this, the comprehension, borrowed from mathematical set-builder notation ({x² | x ∈ S, x odd}). Used well, comprehensions are shorter, clearer and somewhat faster than the equivalent loop. Used badly, they become unreadable puzzles, so this lesson covers both the syntax and its limits.

From loop to list comprehension

Here is the classic build-a-list loop:

squares = []
for n in range(6):
    squares.append(n * n)
print(squares)   # [0, 1, 4, 9, 16, 25]

The same as a list comprehension:

squares = [n * n for n in range(6)]

Read it left to right as "a list of n * n, for each n in range(6)". The pieces:

[ n * n    for n in range(6) ]
  ───┬──   ─────────┬───────
 expression     for clause
 (what to       (where values
  collect)       come from)

The expression can be anything: a method call, an f-string, a tuple, a function call.

names = ["ann", "bo"]
[name.title() for name in names]          # ['Ann', 'Bo']
[f"<{name}>" for name in names]           # ['<ann>', '<bo>']
[(name, len(name)) for name in names]     # [('ann', 3), ('bo', 2)]

Filtering with if

An if clause after the for keeps only the items for which the condition is true:

nums = [4, -1, 7, 0, -3, 2]
positives = [n for n in nums if n > 0]          # [4, 7, 2]
doubled_pos = [2 * n for n in nums if n > 0]    # [8, 14, 4]

The equivalent loop shows the order of operations:

doubled_pos = []
for n in nums:
    if n > 0:
        doubled_pos.append(2 * n)

Do not confuse the filter if (at the end, no else) with a conditional expression in the expression part, which transforms every item:

[n if n > 0 else 0 for n in nums]     # [4, 0, 7, 0, 0, 2]  clamp negatives to 0

Here every item produces an output; the x if cond else y decides which value.

Dict and set comprehensions

Swap the brackets for braces and you get the other two kinds.

A dict comprehension has key: value before the for:

words = ["apple", "fig", "banana"]
lengths = {w: len(w) for w in words}
# {'apple': 5, 'fig': 3, 'banana': 6}

prices = {"tea": 3.5, "cake": 4.0}
with_tax = {item: round(p * 1.25, 2) for item, p in prices.items()}
# {'tea': 4.38, 'cake': 5.0}

inverted = {v: k for k, v in {"a": 1, "b": 2}.items()}
# {1: 'a', 2: 'b'}

If two items produce the same key, the later one wins, just as with repeated assignment.

A set comprehension has a single expression in braces, and duplicates collapse:

emails = ["ann@x.org", "bo@y.com", "cy@x.org"]
domains = {e.split("@")[1] for e in emails}
# {'x.org', 'y.com'}  (in some order: sets are unordered)

Note that {} is an empty dict; an empty set is written set().

Nested for clauses

A comprehension may have several for clauses. They nest left to right, exactly like nested loops, the first being the outermost:

pairs = [(x, y) for x in [1, 2] for y in "ab"]
# [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]

is

pairs = []
for x in [1, 2]:
    for y in "ab":
        pairs.append((x, y))

A common use is flattening a list of lists:

grid = [[1, 2], [3, 4], [5]]
flat = [x for row in grid for x in row]   # [1, 2, 3, 4, 5]

The order "for row in grid for x in row" feels backwards to many people at first. Say it as nested loops: "for each row in grid, for each x in that row, collect x".

A comprehension inside a comprehension is different: it produces a list of lists:

table = [[r * c for c in range(1, 4)] for r in range(1, 4)]
# [[1, 2, 3], [2, 4, 6], [3, 6, 9]]

The inner comprehension runs once per value of the outer r, producing one row each time.

What happens underneath

A comprehension compiles to a tight loop that appends to a new object, using a dedicated LIST_APPEND bytecode instead of looking up and calling the .append method each time. That is why comprehensions are typically a bit faster than the explicit loop. Since Python 3.12 comprehensions are also inlined into the surrounding code rather than run as a hidden nested function, which removed the overhead of a function call.

The loop variable is still local to the comprehension: it does not leak out.

x = "outer"
squares = [x * x for x in range(3)]
print(x)   # outer

(In Python 2 it leaked; in Python 3 it never does.)

Generator expressions: a preview

Write a comprehension with parentheses and you get a generator expression. It does not build a list; it produces an iterator that computes each value on demand:

squares = (n * n for n in range(1_000_000))
print(next(squares), next(squares))   # 0 1

When a generator expression is the only argument to a function, you can drop the extra parentheses:

total = sum(n * n for n in range(1_000_000))

This sums a million squares without ever holding a million-element list in memory. Like every iterator, it is one-shot. Module 13 covers generators properly; for now, prefer this form when you are going to consume the values once, for example in sum, max, any, or "".join(...).

Readability limits

Comprehensions are for building a collection from an iterable. Some guidelines:

active_emails = [
    user["email"].lower()
    for user in users
    if user["active"] and "@" in user["email"]
]

Common mistakes

Putting the filter if first or with an else.

[n for n in nums if n > 0 else 0]
SyntaxError: invalid syntax

A filter if takes no else. If you meant "replace", move it to the front: [n if n > 0 else 0 for n in nums].

Expecting {} to be a set. type({}) is dict. Use set() for an empty set.

Wrong nesting order. [x for x in row for row in grid] raises NameError: name 'row' is not defined (or worse, silently uses an old row variable), because the first for runs first. Write the clauses in the order of the equivalent nested loops.

Shared inner lists. [[0] * 3] * 3 makes three references to one inner list (the aliasing trap from the collections module). [[0] * 3 for _ in range(3)] builds three separate lists. _ is the conventional name for a loop variable you do not use.

Recap

Going further: in the REPL, compare dis.dis("[x*x for x in xs]") with the bytecode of the equivalent loop and spot LIST_APPEND. Then read PEP 709 (inlined comprehensions) for the story of the 3.12 change.

Exercises

Exercise 1 · quiz

Read a comprehension

What does this print?

print([n * n for n in range(6) if n % 2 == 1])
Answer
  • [1, 9, 25]

Read it as a loop: for each n in 0..5, keep it only if n % 2 == 1 (odd: 1, 3, 5), and collect n * n. The result is [1, 9, 25]. The if filters; the expression on the left transforms.

Exercise 2 · quiz

Order of nested for clauses

What does this produce?

[(x, y) for x in "ab" for y in "12"]
Answer
  • [('a', '1'), ('a', '2'), ('b', '1'), ('b', '2')]

Multiple for clauses nest in the order written, left to right, exactly like nested for statements: the first for is the outer loop. So y cycles fastest. The result is flat; to get a list of lists you would nest a whole comprehension inside another.

Exercise 3 · write code

Clean up a word list

Write clean(words) in solution.py using a single list comprehension. It returns the words stripped of surrounding whitespace and lowercased, skipping any that are empty after stripping, in the original order.

clean(["  Apple", "BANANA ", "   ", "", "Cherry"])   # ['apple', 'banana', 'cherry']

File: solution.py

Starting point

def clean(words):
    result = []
    return result
Hint 1

Shape: [EXPRESSION for w in words if CONDITION].

Hint 2

An empty string is falsy, so if w.strip() keeps only non-blank words.

Reference solution
def clean(words):
    return [w.strip().lower() for w in words if w.strip()]

Exercise 4 · write code

Dict and set comprehensions

In solution.py, write two functions, each a single comprehension:

  1. lengths(words) returns a dict mapping each word to its length: lengths(["hi", "there"]) is {"hi": 2, "there": 5}.
  2. initials(words) returns a set of the first letters, uppercased, of all non-empty words: initials(["ann", "Bo", "al", ""]) is {"A", "B"}.

File: solution.py

Starting point

def lengths(words):
    return {}

def initials(words):
    return set()
Hint 1

Dict comprehension: {key_expr: value_expr for w in words}.

Hint 2

Set comprehension: {expr for w in words if w}. Note {} alone is an empty dict, not a set.

Reference solution
def lengths(words):
    return {w: len(w) for w in words}

def initials(words):
    return {w[0].upper() for w in words if w}

Exercise 5 · write code

Transpose a matrix

A matrix is stored as a list of rows, each a list of the same length. Write transpose(matrix) in solution.py that returns a new matrix whose rows are the columns of the original, using a nested comprehension (a comprehension inside a comprehension).

transpose([[1, 2, 3],
           [4, 5, 6]])
# [[1, 4],
#  [2, 5],
#  [3, 6]]

transpose([]) returns []. Think about which loop must be the outer one: the result has one row per column index of the input.

File: solution.py

Starting point

def transpose(matrix):
    return [x for row in matrix for x in row]
Hint 1

The outer comprehension runs over column indexes c in range(len(matrix[0])).

Hint 2

Each new row is [row[c] for row in matrix].

Hint 3

Guard the empty case first, since matrix[0] does not exist.

Reference solution
def transpose(matrix):
    if not matrix:
        return []
    return [[row[c] for row in matrix] for c in range(len(matrix[0]))]