Emmanuel Akinfulubi

Control flow all 4 written

Branching, looping, comprehensions, and the pattern matching added to the language recently.

10 if, elif, else, and what Python calls true

Branching in Python is unremarkable. What is worth real attention is the question Python asks when you write if something: — because it is not "is this True" but "is this truthy", and the difference is the source of a bug that never raises and is easy to ship.

The syntax

if / elif / else, and why the order is part of the logic

ran at build time
def grade(score):
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "F"

for s in [95, 85, 72, 40]:
    print(f"  {s} -> {grade(s)}")

# Conditions are checked in order and the first match wins, so order matters.
# This version is wrong precisely because the order was reversed:
def broken(score):
    if score >= 70:
        return "C"
    elif score >= 90:
        return "A"      # unreachable: anything >= 90 is already >= 70
    return "F"

print()
print("a 95 through the reversed version:", broken(95), "<- the A branch is unreachable")

test("the ordered version grades correctly", [grade(s) for s in (95, 85, 72, 40)], ["A", "B", "C", "F"])
test("reversing the order makes a branch unreachable", broken(95), "C")

What it printed, when the build ran it

  95 -> A
  85 -> B
  72 -> C
  40 -> F

a 95 through the reversed version: C <- the A branch is unreachable
ok   the ordered version grades correctly
ok   reversing the order makes a branch unreachable

There is also an expression form, for when you want a value rather than a branch. It reads oddly at first — the result comes before the condition — and it is worth using only when it fits on one line.

The conditional expression

ran at build time
score = 85

# The conditional expression: value-if-true, condition, value-if-false.
status = "pass" if score >= 60 else "fail"
print("status:", status)

# Which is why this works inline, where a statement could not go.
labels = ["pass" if s >= 60 else "fail" for s in [90, 50, 60]]
print("inline:", labels)

test("the conditional expression yields a value", status, "pass")
test("the boundary is inclusive as written", "pass" if 60 >= 60 else "fail", "pass")
test("it can be used inside a comprehension", labels, ["pass", "fail", "pass"])

What it printed, when the build ran it

status: pass
inline: ['pass', 'fail', 'pass']
ok   the conditional expression yields a value
ok   the boundary is inclusive as written
ok   it can be used inside a comprehension

Truthiness: what counts as true

if x: does not require a boolean. Python asks the object whether it considers itself truthy, and the built-in types all follow the same rule: empty or zero is falsy, everything else is truthy.

Everything falsy, and some things that look like it but are not

ran at build time
values = [0, 0.0, 0j, "", [], {}, set(), None, False,
          1, -1, "a", " ", [0], {"k": 0}, {0}, True]

for v in values:
    print(f"  {repr(v):>10}  ->  {bool(v)}")

test("every empty container is falsy", [bool(x) for x in ([], {}, set(), "")], [False] * 4)
test("zero is falsy in every numeric type", [bool(x) for x in (0, 0.0, 0j)], [False] * 3)
test("a container holding a falsy value is still truthy", bool([0]), True)
test("a string of one space is truthy", bool(" "), True)

What it printed, when the build ran it

           0  ->  False
         0.0  ->  False
          0j  ->  False
          ''  ->  False
          []  ->  False
          {}  ->  False
       set()  ->  False
        None  ->  False
       False  ->  False
           1  ->  True
          -1  ->  True
         'a'  ->  True
         ' '  ->  True
         [0]  ->  True
    {'k': 0}  ->  True
         {0}  ->  True
        True  ->  True
ok   every empty container is falsy
ok   zero is falsy in every numeric type
ok   a container holding a falsy value is still truthy
ok   a string of one space is truthy

Two entries in that table deserve pointing at. [0] is truthy — it is a list with something in it, and what is in it is irrelevant. And " " is truthy, because a space is a character; only the genuinely empty string is falsy.

Prefer truthiness where you mean "has anything in it". if items: is more idiomatic than if len(items) > 0: and works on anything. Use it when emptiness is what you are asking about — and read on for when it is not.

The bug: truthiness conflates "empty" with "absent"

This is the one to remember. A function with None as a default, testing if not items:, cannot tell the difference between "you gave me nothing" and "you gave me an empty list" — and those usually mean different things.

Empty is not the same as missing

ran at build time
def summarise(items=None):
    if not items:                      # catches None AND empty AND 0 AND ""
        return "no data supplied"
    return f"{len(items)} items"

def summarise_correct(items=None):
    if items is None:                  # catches ONLY the absent case
        return "no data supplied"
    return f"{len(items)} items"

print("nothing passed at all:")
print("   truthiness test:", summarise())
print("   is-None test:   ", summarise_correct())
print()
print("an empty list passed deliberately:")
print("   truthiness test:", summarise([]), "<- wrong: data WAS supplied")
print("   is-None test:   ", summarise_correct([]))

test("both agree when the argument really is absent", summarise(), summarise_correct())
test("truthiness misreports a deliberately empty list", summarise([]), "no data supplied")
test("the is-None test gets it right", summarise_correct([]), "0 items")

What it printed, when the build ran it

nothing passed at all:
   truthiness test: no data supplied
   is-None test:    no data supplied

an empty list passed deliberately:
   truthiness test: no data supplied <- wrong: data WAS supplied
   is-None test:    0 items
ok   both agree when the argument really is absent
ok   truthiness misreports a deliberately empty list
ok   the is-None test gets it right

The rule. When a value may legitimately be 0, "", [] or False, test for None explicitly with is None. Reserve bare truthiness for when you really do mean "empty or missing, either way". A search that returned zero results and a search that never ran are different outcomes, and truthiness cannot tell you which you have.

Chained comparisons

Python lets you write 1 < x < 10 and it means what mathematics means, not what C means. The middle expression is evaluated only once, which matters if it is a function call.

Chaining, and the single evaluation of the middle term

ran at build time
x = 5
print("1 < x < 10       :", 1 < x < 10)
print("equivalent to    :", (1 < x) and (x < 10))

# The middle expression is evaluated exactly once -- provable with a counter.
calls = []

def value():
    calls.append(1)
    return 5

result = 1 < value() < 10
print()
print("1 < value() < 10 :", result, " and value() ran", len(calls), "time")

# Chains can be longer, and mix operators.
a, b, c = 1, 2, 3
print()
print("a < b < c        :", a < b < c)
print("a == 1 < b       :", a == 1 < b)

test("chained comparison behaves like mathematics", 1 < x < 10, True)
test("the middle expression is evaluated once", len(calls), 1)
test("chains can mix operators", (a == 1 < b), True)

What it printed, when the build ran it

1 < x < 10       : True
equivalent to    : True

1 < value() < 10 : True  and value() ran 1 time

a < b < c        : True
a == 1 < b       : True
ok   chained comparison behaves like mathematics
ok   the middle expression is evaluated once
ok   chains can mix operators

and and or return operands, not booleans

They short-circuit, and they hand back one of the values they were given rather than True or False. This is occasionally useful and a common source of confusion when the result is printed.

Short-circuiting, and the values these operators actually return

ran at build time
print('"a" and "b"  ->', repr("a" and "b"), "  <- the last one, if all are truthy")
print('"" and "b"   ->', repr("" and "b"), "     <- the first falsy one, and b never evaluated")
print('"" or "b"    ->', repr("" or "b"), "    <- the first truthy one")
print('0 or []      ->', repr(0 or []), "     <- all falsy, so the last one")

# Short-circuiting is what makes this safe.
items = []
if items and items[0] == "x":
    print("never reached")
print()
print("items and items[0] did not raise, because the left side was falsy")

# The old default-value idiom, and why it has the same bug as before.
def greet(name=None):
    return f"hello {name or 'stranger'}"

print()
print("greet():        ", greet())
print("greet(''):      ", greet(""), "<- an empty name became 'stranger'")

test("and returns the last operand when all are truthy", "a" and "b", "b")
test("or returns the first truthy operand", "" or "b", "b")
test("short-circuiting prevents the IndexError", (items and items[0]), [])
test("the or-default idiom has the empty-value bug too", greet(""), "hello stranger")

What it printed, when the build ran it

"a" and "b"  -> 'b'   <- the last one, if all are truthy
"" and "b"   -> ''      <- the first falsy one, and b never evaluated
"" or "b"    -> 'b'     <- the first truthy one
0 or []      -> []      <- all falsy, so the last one

items and items[0] did not raise, because the left side was falsy

greet():         hello stranger
greet(''):       hello stranger <- an empty name became 'stranger'
ok   and returns the last operand when all are truthy
ok   or returns the first truthy operand
ok   short-circuiting prevents the IndexError
ok   the or-default idiom has the empty-value bug too

Comparing with is, once more

From lesson 2, restated because this is where it comes up: use is only against None, True and False. And you rarely need == True at all — if a value is already a boolean, if flag: says it better.

Why == True is worse than just using the value

ran at build time
flag = True
value = 1

# All three are True here, and only one of them is good style.
print("if flag:        ", bool(flag))
print("if flag == True:", flag == True)
print("if flag is True:", flag is True)

# But == True and is True disagree the moment the value is merely truthy.
print()
print("value == True:", value == True, " <- 1 equals True")
print("value is True:", value is True, "<- but 1 is not the True object")
print("bool(value)  :", bool(value), " <- what you actually meant")

test("1 compares equal to True", value == True, True)
test("but is not the same object", value is True, False)
test("truthiness is the question you wanted", bool(value), True)

What it printed, when the build ran it

if flag:         True
if flag == True: True
if flag is True: True

value == True: True  <- 1 equals True
value is True: False <- but 1 is not the True object
bool(value)  : True  <- what you actually meant
ok   1 compares equal to True
ok   but is not the same object
ok   truthiness is the question you wanted

What to take from this lesson

  • Branches are checked in order; the first match wins, so a broader condition placed first makes later ones unreachable.
  • Empty and zero are falsy; everything else is truthy, including [0] and " ".
  • Use if x is None when 0, "" or [] are legitimate values. This is the bug worth remembering from this lesson.
  • and and or return one of their operands, and short-circuit.
  • Write if flag:, not if flag == True:.

11 Loops, and the two you usually do not need

Python's for is not a counter. It is a "give me each item in turn" loop, and it works on anything iterable. Once that lands, most of the loop code you would write in other languages turns out to be unnecessary here.

The loop you will write most

for, over four different iterables

ran at build time
names = ["Ada", "Alan", "Grace"]

for name in names:
    print("  hello", name)

# It works on anything iterable, not just lists.
print()
for ch in "abc":
    print("  char:", ch)
for key in {"x": 1, "y": 2}:
    print("  key:", key)

total = 0
for n in [1, 2, 3, 4]:
    total += n
print()
print("total:", total)

test("the loop visits every item", [n for n in names], names)
test("strings iterate by character", [c for c in "abc"], ["a", "b", "c"])
test("accumulating in a loop works as expected", total, 10)

What it printed, when the build ran it

  hello Ada
  hello Alan
  hello Grace

  char: a
  char: b
  char: c
  key: x
  key: y

total: 10
ok   the loop visits every item
ok   strings iterate by character
ok   accumulating in a loop works as expected

The first loop you do not need: indexing by range(len(...))

If you want the items, ask for the items. If you want the position as well, enumerate gives you both — and it is not a convenience, it is the idiom.

enumerate replaces range(len(...))

ran at build time
names = ["Ada", "Alan", "Grace"]

# The C-style habit.
print("indexing by hand:")
for i in range(len(names)):
    print(f"  {i}: {names[i]}")

# The Python version.
print()
print("with enumerate:")
for i, name in enumerate(names):
    print(f"  {i}: {name}")

# And numbering for humans, which is the argument nobody discovers at first.
print()
print("numbered from one:")
for i, name in enumerate(names, start=1):
    print(f"  {i}. {name}")

test("enumerate yields index and item", list(enumerate(["a", "b"])), [(0, "a"), (1, "b")])
test("start= shifts the numbering only", list(enumerate(["a"], start=1)), [(1, "a")])

What it printed, when the build ran it

indexing by hand:
  0: Ada
  1: Alan
  2: Grace

with enumerate:
  0: Ada
  1: Alan
  2: Grace

numbered from one:
  1. Ada
  2. Alan
  3. Grace
ok   enumerate yields index and item
ok   start= shifts the numbering only

When you need to walk two sequences together, the same principle applies: zip rather than a shared index.

zip, and the truncation to watch for

ran at build time
names = ["Ada", "Alan", "Grace"]
years = [1815, 1912, 1906]

for name, year in zip(names, years):
    print(f"  {name}: {year}")

# zip stops at the shortest input, silently.
print()
print("mismatched lengths:", list(zip([1, 2, 3], ["a"])))

# Which can hide a bug, so say what you mean when they must match.
refused = False
try:
    list(zip([1, 2, 3], ["a"], strict=True))
except ValueError as exc:
    refused = True
    print("with strict=True:", exc)

test("zip pairs them up", list(zip([1, 2], ["a", "b"])), [(1, "a"), (2, "b")])
test("zip truncates to the shortest by default", len(list(zip([1, 2, 3], ["a"]))), 1)
test("strict=True raises instead of truncating", refused, True)

What it printed, when the build ran it

  Ada: 1815
  Alan: 1912
  Grace: 1906

mismatched lengths: [(1, 'a')]
with strict=True: zip() argument 2 is shorter than argument 1
ok   zip pairs them up
ok   zip truncates to the shortest by default
ok   strict=True raises instead of truncating

The second loop you rarely need: while with a manual counter

while is for when you genuinely do not know how many iterations there will be — reading until input runs out, retrying until something succeeds, converging on a value. Using it to count is a longer, more error-prone for.

while, used badly and then used well

ran at build time
# Counting with while: three places to make a mistake.
result = []
i = 0
while i < 3:
    result.append(i)
    i += 1              # forget this line and the program never ends
print("while, counting:", result)

# The same thing, with nothing to forget.
print("for,   counting:", list(range(3)))

# What while IS for: an unknown number of steps.
def collatz_steps(n):
    steps = 0
    while n != 1:
        n = n // 2 if n % 2 == 0 else 3 * n + 1
        steps += 1
    return steps

print()
print("steps to reach 1 from 27:", collatz_steps(27), "<- not knowable in advance")

test("both counting versions agree", result, list(range(3)))
test("while suits an unpredictable number of steps", collatz_steps(27), 111)

What it printed, when the build ran it

while, counting: [0, 1, 2]
for,   counting: [0, 1, 2]

steps to reach 1 from 27: 111 <- not knowable in advance
ok   both counting versions agree
ok   while suits an unpredictable number of steps

range, precisely

range is a rule, not a list

ran at build time
print("range(5)       ->", list(range(5)))
print("range(2, 5)    ->", list(range(2, 5)))
print("range(0, 10, 3) ->", list(range(0, 10, 3)))
print("range(5, 0, -1) ->", list(range(5, 0, -1)))

# The stop is excluded, which is why these two fit together exactly.
print()
print("range(0,3) + range(3,6) ->", list(range(0, 3)) + list(range(3, 6)))

# range does not build a list -- it computes values on demand.
import sys
print()
print("size of range(1_000_000):", sys.getsizeof(range(1_000_000)), "bytes")
print("size of the list of them:", sys.getsizeof(list(range(1_000_000))), "bytes")

test("the stop value is excluded", list(range(5)), [0, 1, 2, 3, 4])
test("a negative step counts down", list(range(3, 0, -1)), [3, 2, 1])
test("range holds no elements, whatever its length", sys.getsizeof(range(10)) == sys.getsizeof(range(10 ** 6)), True)

What it printed, when the build ran it

range(5)       -> [0, 1, 2, 3, 4]
range(2, 5)    -> [2, 3, 4]
range(0, 10, 3) -> [0, 3, 6, 9]
range(5, 0, -1) -> [5, 4, 3, 2, 1]

range(0,3) + range(3,6) -> [0, 1, 2, 3, 4, 5]

size of range(1_000_000): 48 bytes
size of the list of them: 8000056 bytes
ok   the stop value is excluded
ok   a negative step counts down
ok   range holds no elements, whatever its length

break, continue, and the else nobody expects

break leaves the loop; continue skips to the next iteration. A loop can also have an else, and its meaning is genuinely counter-intuitive: it runs when the loop finished without breaking. Read it as "if no break happened" and it stops being confusing.

break, continue, and for-else

ran at build time
def find_first_even(numbers):
    for n in numbers:
        if n % 2 == 0:
            return f"found {n}"
    return "no even number"

print(find_first_even([1, 3, 4, 5]))
print(find_first_even([1, 3, 5]))

# continue: skip some items without nesting an if around the whole body.
kept = []
for n in range(8):
    if n % 3 != 0:
        continue
    kept.append(n)
print()
print("multiples of three:", kept)

# for-else: the else runs only if no break occurred.
def check_all_positive(numbers):
    for n in numbers:
        if n <= 0:
            return f"{n} is not positive"
    else:
        return "all positive"

print()
print(check_all_positive([1, 2, 3]))
print(check_all_positive([1, -2, 3]))

# Proving when else runs, with a log.
log = []
for n in [1, 2, 3]:
    if n == 99:
        log.append("broke")
        break
else:
    log.append("else ran")
print()
print("no break happened, so:", log)

test("continue filters without nesting", kept, [0, 3, 6])
test("the else clause runs when nothing broke", log, ["else ran"])
test("for-else reads as 'if no break'", check_all_positive([1, 2]), "all positive")

What it printed, when the build ran it

found 4
no even number

multiples of three: [0, 3, 6]

all positive
-2 is not positive

no break happened, so: ['else ran']
ok   continue filters without nesting
ok   the else clause runs when nothing broke
ok   for-else reads as 'if no break'

Where for-else earns its keep. Searching for something and needing to act if it was never found. Without it you need a flag variable set before the loop and checked after; with it the "not found" branch is attached to the loop itself. It is the one piece of Python syntax most people have to look up every time, so a comment saying "else: no break" is a kindness.

Nested loops, and why to avoid deep nesting

break escapes one level, and the two ways round it

ran at build time
# Two loops, and the thing beginners expect break to do.
found = None
for row in range(3):
    for col in range(3):
        if row * col == 4:
            found = (row, col)
            break                # leaves the INNER loop only
    if found:
        break                    # needs its own break to leave the outer one
print("found:", found)

# Extracting a function replaces both breaks with a return.
def search():
    for row in range(3):
        for col in range(3):
            if row * col == 4:
                return (row, col)
    return None

print("via a function:", search())

# Or flatten the nesting away entirely.
from itertools import product
print("via itertools.product:", next(((r, c) for r, c in product(range(3), range(3)) if r * c == 4)))

test("break only leaves the innermost loop", found, (2, 2))
test("a function lets one return leave both", search(), (2, 2))

What it printed, when the build ran it

found: (2, 2)
via a function: (2, 2)
via itertools.product: (2, 2)
ok   break only leaves the innermost loop
ok   a function lets one return leave both

What to take from this lesson

  • for item in things is the default. Reach for enumerate when you also need the position and zip when you are walking two sequences.
  • zip silently stops at the shortest input; pass strict=True when equal lengths are a requirement.
  • Use while only when the number of iterations is genuinely unknown.
  • range excludes its stop value and stores no elements.
  • for-else runs when the loop did not break.
  • break leaves one level; extracting a function and returning is usually clearer than flag variables.

12 Comprehensions, and when they stop helping

A comprehension builds a container from an iterable in one expression. It is the most recognisably Python construct there is, and the most over-used: the same feature that turns four lines into one also turns one readable line into something nobody wants to debug.

The four forms

List, set and dict comprehensions

ran at build time
numbers = [1, 2, 3, 4, 5]

# List: [expression for item in iterable]
squares = [n * n for n in numbers]

# With a condition, which filters.
evens = [n for n in numbers if n % 2 == 0]

# Set: same syntax, braces. Deduplicates.
lengths = {len(w) for w in ["fig", "apple", "plum", "kiwi"]}

# Dict: key and value, separated by a colon.
by_square = {n: n * n for n in numbers}

print("squares:  ", squares)
print("evens:    ", evens)
print("lengths:  ", lengths, " <- a set, so 4 appears once")
print("by_square:", by_square)

test("a list comprehension maps", squares, [1, 4, 9, 16, 25])
test("the if clause filters", evens, [2, 4])
test("a set comprehension deduplicates", lengths, {3, 4, 5})
test("a dict comprehension builds pairs", by_square[3], 9)

What it printed, when the build ran it

squares:   [1, 4, 9, 16, 25]
evens:     [2, 4]
lengths:   {3, 4, 5}  <- a set, so 4 appears once
by_square: {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
ok   a list comprehension maps
ok   the if clause filters
ok   a set comprehension deduplicates
ok   a dict comprehension builds pairs

The loop variable is local to the comprehension. It does not leak into the surrounding code, which is a genuine difference from a normal for loop.

Comprehension scope versus loop scope

ran at build time
squares = [n * n for n in range(3)]
print("built:", squares)
print("is 'n' a name out here?", "n" in dir())

# A normal for loop DOES leave its variable behind.
for m in range(3):
    pass
print("is 'm' a name out here?", "m" in dir(), " <- yes, loops leak")

test("the comprehension variable does not escape", "n" in dir(), False)
test("an ordinary for loop's variable does", "m" in dir(), True)

What it printed, when the build ran it

built: [0, 1, 4]
is 'n' a name out here? False
is 'm' a name out here? True  <- yes, loops leak
ok   the comprehension variable does not escape
ok   an ordinary for loop's variable does

The condition, and conditional values

There are two places an if can appear, and they do different jobs. After the for it filters items out. Before the for, as part of a conditional expression, it chooses between values and keeps every item.

Filtering versus choosing a value

ran at build time
numbers = [1, 2, 3, 4]

# if at the END filters: fewer items come out.
filtered = [n for n in numbers if n % 2 == 0]

# if at the START chooses a value: the same number of items come out.
labelled = ["even" if n % 2 == 0 else "odd" for n in numbers]

# Both at once, which is legal and usually a sign to stop.
both = ["big" if n > 3 else "small" for n in numbers if n != 2]

print("filtered:", filtered, " <-", len(filtered), "of", len(numbers))
print("labelled:", labelled, " <-", len(labelled), "of", len(numbers))
print("both:    ", both)

test("a trailing if removes items", len(filtered), 2)
test("a leading conditional keeps them all", len(labelled), len(numbers))
test("they can combine", both, ["small", "small", "big"])

What it printed, when the build ran it

filtered: [2, 4]  <- 2 of 4
labelled: ['odd', 'even', 'odd', 'even']  <- 4 of 4
both:     ['small', 'small', 'big']
ok   a trailing if removes items
ok   a leading conditional keeps them all
ok   they can combine

Nesting, and the order that surprises everyone

A comprehension can have more than one for. The clauses read left to right in the same order you would nest the equivalent loops — which is not the order most people guess, because the expression sits at the far left while the outermost loop is just after it.

Two for clauses flatten; a nested comprehension nests

ran at build time
# Flattening: the outer loop comes first, exactly as in nested for loops.
grid = [[1, 2], [3, 4], [5]]
flat = [n for row in grid for n in row]
print("flattened:", flat)

# The equivalent loops, to compare the order directly.
manual = []
for row in grid:           # first clause
    for n in row:          # second clause
        manual.append(n)   # the expression
print("by hand:  ", manual)

# Getting the order backwards is a NameError, which is a helpful failure.
print()
print("writing the clauses the other way round would reference 'row' before it exists")

# Nested comprehension (a comprehension as the expression) builds nested output.
matrix = [[r * c for c in range(3)] for r in range(3)]
print()
print("a comprehension inside a comprehension:")
for row in matrix:
    print("  ", row)

test("flattening produces a flat list", flat, [1, 2, 3, 4, 5])
test("it matches the nested loops exactly", flat, manual)
test("nesting the comprehension nests the output", matrix[2], [0, 2, 4])

What it printed, when the build ran it

flattened: [1, 2, 3, 4, 5]
by hand:   [1, 2, 3, 4, 5]

writing the clauses the other way round would reference 'row' before it exists

a comprehension inside a comprehension:
   [0, 0, 0]
   [0, 1, 2]
   [0, 2, 4]
ok   flattening produces a flat list
ok   it matches the nested loops exactly
ok   nesting the comprehension nests the output

Generator expressions: the same syntax, none of the memory

Round brackets instead of square ones produce a generator: it computes values one at a time as something asks for them, and never holds the whole sequence. For a handful of items this is irrelevant. For a large or infinite sequence it is the difference between working and not.

A generator expression, and the one-shot cost of it

ran at build time
import sys

as_list = [n * n for n in range(100_000)]
as_generator = (n * n for n in range(100_000))

print("list comprehension:", f"{sys.getsizeof(as_list):>8,} bytes")
print("generator:         ", f"{sys.getsizeof(as_generator):>8,} bytes")
print()
print("Both describe the same 100,000 values. Only one of them is storing them.")

# A generator is consumed once, which is the cost of the saving.
gen = (n for n in range(3))
print()
print("first pass: ", list(gen))
print("second pass:", list(gen), "<- exhausted")

# And when a generator is the only argument, the brackets can be dropped.
print()
print("sum of squares:", sum(n * n for n in range(5)))

test("the generator uses far less memory", sys.getsizeof(as_generator) < sys.getsizeof(as_list), True)
test("a generator yields the right values", list(n for n in range(3)), [0, 1, 2])
test("but only once", (lambda g: (list(g), list(g)))((n for n in range(3))), ([0, 1, 2], []))
test("sum() accepts a bare generator expression", sum(n * n for n in range(5)), 30)

What it printed, when the build ran it

list comprehension:  800,984 bytes
generator:               208 bytes

Both describe the same 100,000 values. Only one of them is storing them.

first pass:  [0, 1, 2]
second pass: [] <- exhausted

sum of squares: 30
ok   the generator uses far less memory
ok   a generator yields the right values
ok   but only once
ok   sum() accepts a bare generator expression

When to reach for which. Use a list comprehension when you need the result more than once, need its length, or need to index it. Use a generator expression when you are feeding it straight into sum, any, max, a for loop, or anything else that consumes it exactly once — and especially when the sequence is large. Lesson 34 goes further into generators.

any and all, which short-circuit

any and all, and their answers for an empty sequence

ran at build time
numbers = [1, 2, 3, 4]

print("any even?", any(n % 2 == 0 for n in numbers))
print("all even?", all(n % 2 == 0 for n in numbers))

# They stop as soon as the answer is known -- provable with a counter.
checked = []

def is_big(n):
    checked.append(n)
    return n > 2

print()
print("any(is_big(n) for n in [1,2,3,4]) ->", any(is_big(n) for n in numbers))
print("and it only examined:", checked, "<- stopped at the first True")

# The empty-sequence answers, which catch people out.
print()
print("any([]) ->", any([]), " (no evidence for)")
print("all([]) ->", all([]), "  (no evidence against)")

test("any is True when at least one is", any(n % 2 == 0 for n in numbers), True)
test("all is False when one is not", all(n % 2 == 0 for n in numbers), False)
test("any short-circuits at the first True", checked, [1, 2, 3])
test("any of nothing is False, all of nothing is True", (any([]), all([])), (False, True))

What it printed, when the build ran it

any even? True
all even? False

any(is_big(n) for n in [1,2,3,4]) -> True
and it only examined: [1, 2, 3] <- stopped at the first True

any([]) -> False  (no evidence for)
all([]) -> True   (no evidence against)
ok   any is True when at least one is
ok   all is False when one is not
ok   any short-circuits at the first True
ok   any of nothing is False, all of nothing is True

When a comprehension stops helping

This is the part usually left out. A comprehension is good when it reads as one idea. Once it carries two conditions, a nested loop and a conditional value, it has become a puzzle — and the loop version is not worse, it is better.

The same logic twice, once unreadably

ran at build time
records = [
    {"name": "Ada", "age": 36, "tags": ["maths", "logic"]},
    {"name": "Alan", "age": 41, "tags": ["logic"]},
    {"name": "Grace", "age": 36, "tags": []},
]

# Technically one expression. Genuinely hard to read.
dense = [
    (r["name"].upper(), t)
    for r in records
    if r["age"] < 40
    for t in r["tags"]
    if t != "logic"
]

# The same thing, as a loop. Longer, and you can debug it.
clear = []
for r in records:
    if r["age"] >= 40:
        continue
    for tag in r["tags"]:
        if tag == "logic":
            continue
        clear.append((r["name"].upper(), tag))

print("comprehension:", dense)
print("loop:         ", clear)
print()
print("Identical results. The second one you can put a breakpoint in.")

test("both produce the same answer", dense, clear)
test("and the answer is what we wanted", clear, [("ADA", "maths")])

What it printed, when the build ran it

comprehension: [('ADA', 'maths')]
loop:          [('ADA', 'maths')]

Identical results. The second one you can put a breakpoint in.
ok   both produce the same answer
ok   and the answer is what we wanted

A usable rule. One for and at most one if, on one line: a comprehension is clearer. Beyond that, write the loop. The goal is not fewer lines; a reviewer who has to trace the clause order to know what the code does is paying for your brevity. And if a comprehension has side effects — calling something for its effect and discarding the list — it should have been a loop from the start.

What to take from this lesson

  • Square brackets for a list, braces for a set or dict, round brackets for a generator.
  • if after the for filters; a conditional expression before it chooses a value.
  • Multiple for clauses read in the same order as the nested loops they replace.
  • Generator expressions hold nothing and can be consumed once; prefer them when feeding sum, any, all or a loop.
  • Stop at one for and one if. Past that, a loop is the more readable choice.

13 match: structural pattern matching

Added in Python 3.10, match is routinely described as Python's switch statement. It is not, and reading it that way leads to worse code than plain if/elif. What it really does is test the shape of a value and pull its pieces out in the same step.

Matching literals, where it does look like a switch

Literal patterns and the wildcard

ran at build time
def http_meaning(status):
    match status:
        case 200:
            return "OK"
        case 301 | 302:            # | means "or", not bitwise or
            return "redirect"
        case 404:
            return "not found"
        case _:                    # the wildcard: matches anything
            return "something else"

for code in [200, 302, 404, 500]:
    print(f"  {code} -> {http_meaning(code)}")

test("a literal pattern matches", http_meaning(200), "OK")
test("an or-pattern matches either", (http_meaning(301), http_meaning(302)), ("redirect", "redirect")) 
test("the wildcard catches the rest", http_meaning(500), "something else")

What it printed, when the build ran it

  200 -> OK
  302 -> redirect
  404 -> not found
  500 -> something else
ok   a literal pattern matches
ok   an or-pattern matches either
ok   the wildcard catches the rest

There is no fall-through: the first matching case runs and nothing else, so no break is needed or allowed. Written this way it is a tidier if/elif chain and little more — which is why stopping here misses the point.

What it is actually for: destructuring

A pattern can describe a structure and bind names to the parts of it that matched. This is the feature, and it has no concise equivalent in if/elif.

Sequence patterns: testing length and binding parts at once

ran at build time
def describe(value):
    match value:
        case []:
            return "an empty sequence"
        case [single]:
            return f"one item: {single}"
        case [first, second]:
            return f"two items: {first} and {second}"
        case [first, *rest]:
            return f"starts with {first}, then {len(rest)} more"
        case _:
            return "not a sequence"

for v in [[], [9], [1, 2], [1, 2, 3, 4], "nope"]:
    print(f"  {repr(v):>14} -> {describe(v)}")

test("an empty sequence pattern matches", describe([]), "an empty sequence")
test("length-specific patterns bind their parts", describe([1, 2]), "two items: 1 and 2")
test("a starred pattern absorbs the remainder", describe([1, 2, 3, 4]), "starts with 1, then 3 more")

What it printed, when the build ran it

              [] -> an empty sequence
             [9] -> one item: 9
          [1, 2] -> two items: 1 and 2
    [1, 2, 3, 4] -> starts with 1, then 3 more
          'nope' -> not a sequence
ok   an empty sequence pattern matches
ok   length-specific patterns bind their parts
ok   a starred pattern absorbs the remainder

A string is a sequence, but match deliberately does not treat it as one. case [first, *rest] does not match "abc", and that is a design decision rather than an oversight — matching strings character-wise would almost never be what anyone meant. str, bytes and bytearray are all excluded from sequence patterns.

Mapping patterns match on the keys present

A mapping pattern checks for the keys it names and ignores any others — a partial match, not an exact one. That is usually what you want when handling JSON-shaped data, where extra fields are normal.

Mapping patterns are partial by design

ran at build time
def handle(event):
    match event:
        case {"type": "click", "x": x, "y": y}:
            return f"click at {x},{y}"
        case {"type": "key", "key": k}:
            return f"key {k!r}"
        case {"type": t, **rest}:
            return f"unhandled {t!r} with {len(rest)} other field(s)"
        case _:
            return "not an event"

events = [
    {"type": "click", "x": 10, "y": 20},
    {"type": "click", "x": 1, "y": 2, "button": "left"},   # extra key: still matches
    {"type": "key", "key": "Esc"},
    {"type": "scroll", "delta": 3, "axis": "y"},
]
for e in events:
    print(f"  {handle(e)}")

test("a mapping pattern binds the named keys", handle({"type": "click", "x": 1, "y": 2}), "click at 1,2")
test("extra keys do not prevent a match", handle({"type": "key", "key": "a", "mods": []}), "key 'a'")
test("**rest collects the unnamed keys", handle({"type": "scroll", "delta": 3, "axis": "y"}),
     "unhandled 'scroll' with 2 other field(s)")

What it printed, when the build ran it

  click at 10,20
  click at 1,2
  key 'Esc'
  unhandled 'scroll' with 2 other field(s)
ok   a mapping pattern binds the named keys
ok   extra keys do not prevent a match
ok   **rest collects the unnamed keys

Class patterns, which is where it gets genuinely expressive

Class patterns with a guard

ran at build time
from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rect:
    width: float
    height: float

def area(shape):
    match shape:
        case Circle(radius=0) | Rect(width=0) | Rect(height=0):
            return 0.0
        case Circle(radius=r):
            return round(3.14159 * r * r, 2)
        case Rect(width=w, height=h) if w == h:
            return f"square of area {w * h}"
        case Rect(width=w, height=h):
            return w * h
        case _:
            raise TypeError(f"not a shape: {shape!r}")

for s in [Circle(0), Circle(2), Rect(3, 3), Rect(2, 5)]:
    print(f"  {str(s):>22} -> {area(s)}")

test("a class pattern matches the type and reads attributes", area(Circle(2)), 12.57)
test("a guard distinguishes the square case", area(Rect(3, 3)), "square of area 9")
test("degenerate cases are caught first", area(Circle(0)), 0.0)
test("an ordinary rectangle falls to the general case", area(Rect(2, 5)), 10)

What it printed, when the build ran it

        Circle(radius=0) -> 0.0
        Circle(radius=2) -> 12.57
  Rect(width=3, height=3) -> square of area 9
  Rect(width=2, height=5) -> 10
ok   a class pattern matches the type and reads attributes
ok   a guard distinguishes the square case
ok   degenerate cases are caught first
ok   an ordinary rectangle falls to the general case

Notice the if w == h on one of the cases. A guard is an extra condition that must also hold for the case to match; if it fails, matching continues with the next case rather than giving up.

The trap: a bare name captures, it does not compare

This is the sharpest edge in the whole feature. A plain name in a pattern is a binding, not a comparison: it matches anything and assigns to that name. So writing a constant's name to compare against it instead matches everything.

Write that with cases after it and the compiler now stops you outright — this is a SyntaxError, raised before the file runs:

The compiler refuses the unreachable case

raises SyntaxError
STATUS_OK = 200

def check(status):
    match status:
        case STATUS_OK:
            return "matched"
        case 404:
            return "not found"

The traceback it really produced, when the build ran it

  File "example.py", line 5
    case STATUS_OK:
         ^^^^^^^^^
SyntaxError: name capture 'STATUS_OK' makes remaining patterns unreachable

Read that message carefully, because it names the mechanism exactly: name capture makes the remaining patterns unreachable. Being caught at compile time is a real improvement — on older versions this ran happily and silently swallowed every status.

But the check only fires when something comes after it. A bare-name capture in the last position is legal, because nothing is made unreachable. It still matches everything, and it still quietly rebinds the name you thought you were comparing against — which is the version of this trap that survives into 3.14.

The surviving version: a capture in the last position

ran at build time
STATUS_OK = 200
print("before the match, STATUS_OK is", STATUS_OK)

status = 1                       # nothing like 200 and nothing like 404

match status:
    case 404:
        print("not found")
    case STATUS_OK:              # legal as the last case, and matches ANYTHING
        print("fell through to the 'constant' case")

print("after the match, STATUS_OK is", STATUS_OK, "<- the constant is gone")

test("the pattern matched a value it should not have", status, 1)
test("and the 'constant' was overwritten with it", STATUS_OK, 1)

What it printed, when the build ran it

before the match, STATUS_OK is 200
fell through to the 'constant' case
after the match, STATUS_OK is 1 <- the constant is gone
ok   the pattern matched a value it should not have
ok   and the 'constant' was overwritten with it

Inside a function the damage is narrower but the confusion is the same: the capture creates a local name, so the module-level constant survives while every mention of it inside that function silently refers to the matched value instead.

The same mistake inside a function shadows instead of overwriting

ran at build time
LIMIT = 100

def classify(n):
    match n:
        case 0:
            return "zero"
        case LIMIT:              # a local binding, shadowing the global
            return f"anything else; LIMIT reads as {LIMIT} in here"

print("classify(7):", classify(7))
print("classify(0):", classify(0))
print()
print("the module-level LIMIT is untouched:", LIMIT)

test("the function saw its own local binding", classify(7), "anything else; LIMIT reads as 7 in here")
test("while the global constant survived", LIMIT, 100)

What it printed, when the build ran it

classify(7): anything else; LIMIT reads as 7 in here
classify(0): zero

the module-level LIMIT is untouched: 100
ok   the function saw its own local binding
ok   while the global constant survived

The fix in both cases is the same: give the constant a dot, which makes it a value pattern that is compared rather than bound.

A dotted name is compared, not bound

ran at build time
class Status:
    OK = 200

def fixed(status):
    match status:
        case Status.OK:          # dotted, so compared
            return "OK"
        case 404:
            return "not found"
    return "no match"

for s in [200, 404, 999]:
    print(f"  fixed({s}) -> {fixed(s)}")

test("a dotted name is compared", fixed(200), "OK")
test("so later cases stay reachable", fixed(404), "not found")
test("and a genuine non-match falls through", fixed(999), "no match")

What it printed, when the build ran it

  fixed(200) -> OK
  fixed(404) -> not found
  fixed(999) -> no match
ok   a dotted name is compared
ok   so later cases stay reachable
ok   and a genuine non-match falls through

The rule that avoids all of it. Constants used in patterns must be reached through a dot — Status.OK, HTTPStatus.NOT_FOUND, self.EXPECTED. An Enum is the natural home for them. And prefer case _: for the catch-all, which says "anything" out loud instead of accidentally binding a name.

Binding the whole match with as

as, type patterns, and guards together

ran at build time
def normalise(value):
    match value:
        case int() | float() as number:
            return f"number: {number}"
        case str() as text if text.strip():
            return f"non-empty string: {text.strip()!r}"
        case str():
            return "blank string"
        case [int() | float(), *_] as numbers:
            return f"sequence beginning with a number, length {len(numbers)}"
        case _:
            return "unrecognised"

for v in [42, 3.5, "  hi  ", "   ", [1, "a"], {"k": 1}]:
    print(f"  {repr(v):>10} -> {normalise(v)}")

test("as binds the value that matched the whole pattern", normalise(42), "number: 42")
test("a guard can inspect the bound name", normalise("  hi  "), "non-empty string: 'hi'")
test("order matters: the blank string falls to the later case", normalise("   "), "blank string")
test("type patterns work inside sequence patterns", normalise([1, "a"]),
     "sequence beginning with a number, length 2")

What it printed, when the build ran it

          42 -> number: 42
         3.5 -> number: 3.5
    '  hi  ' -> non-empty string: 'hi'
       '   ' -> blank string
    [1, 'a'] -> sequence beginning with a number, length 2
    {'k': 1} -> unrecognised
ok   as binds the value that matched the whole pattern
ok   a guard can inspect the bound name
ok   order matters: the blank string falls to the later case
ok   type patterns work inside sequence patterns

When to use it, and when not to

Reach for match when the shape of the data is the question. Handling messages of different forms, walking a syntax tree, interpreting API responses, dispatching on a mix of type and contents — these are cases where the alternative is a pile of isinstance checks and indexing that can fail. Stay with if/elif when you are testing a single value against a few alternatives, and use a dict when you are mapping a key to a value or a function. A match with four literal cases and no destructuring is a dict written the long way.

A dict is often the better switch

ran at build time
# What a four-literal match should usually have been.
def with_match(cmd):
    match cmd:
        case "start":
            return 1
        case "stop":
            return 2
        case _:
            return 0

ACTIONS = {"start": 1, "stop": 2}
def with_dict(cmd):
    return ACTIONS.get(cmd, 0)

for c in ["start", "stop", "other"]:
    print(f"  {c:>6}: match={with_match(c)}  dict={with_dict(c)}")

print()
print("Same behaviour. The dict is data, so it can be extended without touching the logic.")

test("both dispatch identically", [with_match(c) for c in ("start", "stop", "x")],
     [with_dict(c) for c in ("start", "stop", "x")])

What it printed, when the build ran it

   start: match=1  dict=1
    stop: match=2  dict=2
   other: match=0  dict=0

Same behaviour. The dict is data, so it can be extended without touching the logic.
ok   both dispatch identically

What to take from this lesson

  • match tests structure and binds parts in one step. There is no fall-through.
  • Sequence, mapping and class patterns are the reason to use it; mapping patterns are partial, and strings are deliberately not sequences here.
  • A guard (if on a case) narrows a match without ending it.
  • A bare name captures and matches anything. Constants must be dotted, or every later case is dead.
  • For a handful of literals with no destructuring, a dict or if/elif is clearer.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.