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
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.
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.
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.
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.
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.
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.
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 Nonewhen0,""or[]are legitimate values. This is the bug worth remembering from this lesson. andandorreturn one of their operands, and short-circuit.- Write
if flag:, notif 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
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.
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.
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.
# 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
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.
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
# 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 thingsis the default. Reach forenumeratewhen you also need the position andzipwhen you are walking two sequences.zipsilently stops at the shortest input; passstrict=Truewhen equal lengths are a requirement.- Use
whileonly when the number of iterations is genuinely unknown. rangeexcludes its stop value and stores no elements.for-elseruns when the loop did notbreak.breakleaves 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
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.
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.
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.
# 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.
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
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.
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.
ifafter theforfilters; a conditional expression before it chooses a value.- Multiple
forclauses 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,allor a loop. - Stop at one
forand oneif. 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
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.
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.
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
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:
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.
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.
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.
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
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.
# 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
matchtests 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 (
ifon 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/elifis clearer.