Making Decisions and Repeating Work
Python's control flow is small: if, for, while, match, and three words that change where a loop goes. What makes it worth a chapter is that several of these do something slightly different from their equivalents in other languages, and the differences are where the bugs live.
for iterates over things, not indices
names = ["Aisha", "Noshad", "Sam"]
# Not this:
for i in range(len(names)):
print(names[i])
# This:
for name in names:
print(name)
Open this example in the editor and change it. Nothing you type is saved.
The second is shorter, but that is not the argument. The argument is that it works for anything iterable - a file, a generator, a dictionary, a database cursor - while the first only works for things that have a length and support integer indexing.
When you genuinely need the position, enumerate gives you both, and start= sets where counting begins:
names = ["Aisha", "Noshad", "Sam"]
for position, name in enumerate(names, start=1):
print(f"{position}. {name}")
# 1. Aisha
# 2. Noshad
# 3. Sam
Open this example in the editor and change it. Nothing you type is saved.
Two sequences at once: zip stops at the shorter one, silently, which is sometimes what you want and sometimes a lost row.
names = ["Aisha", "Noshad", "Sam"]
scores = [91, 78]
print(list(zip(names, scores)))
# [('Aisha', 91), ('Noshad', 78)] - Sam vanished
try:
print(list(zip(names, scores, strict=True)))
except ValueError as error:
print(type(error).__name__) # ValueError
Open this example in the editor and change it. Nothing you type is saved.
strict=True turns that silent truncation into an error. Use it whenever the two sequences are supposed to be the same length - which is most of the time.
from itertools import zip_longest
names = ["Aisha", "Noshad", "Sam"]
scores = [91, 78]
print(list(zip_longest(names, scores, fillvalue=0)))
# [('Aisha', 91), ('Noshad', 78), ('Sam', 0)]
Open this example in the editor and change it. Nothing you type is saved.
Iterating a dictionary
scores = {"aisha": 91, "noshad": 78, "sam": 64}
for key in scores: # keys, by default
pass
for value in scores.values():
pass
for key, value in scores.items(): # the one you usually want
print(f"{key}: {value}")
Open this example in the editor and change it. Nothing you type is saved.
Since 3.7 a dict keeps insertion order, and that is a language guarantee rather than an implementation detail you may rely on by accident.
break, continue, and the else nobody expects
numbers = [4, 9, 16, 23, 42]
for n in numbers:
if n % 2 == 1:
print("found an odd number:", n)
break
else:
print("all even")
# found an odd number: 9
Open this example in the editor and change it. Nothing you type is saved.
A loop's else runs when the loop finished without hitting break. It is not "otherwise" - it is "no break". That makes it exactly right for search loops, where you need to distinguish "found it" from "ran out of items":
def find_first_odd(values):
for value in values:
if value % 2 == 1:
return value
return None # often clearer than for/else
Open this example in the editor and change it. Nothing you type is saved.
Honestly: a function with an early return is usually clearer than for/else, and most codebases prefer it. Learn for/else so you can read it; reach for it only when extracting a function would be artificial.
continue skips to the next iteration, which flattens a nested if:
rows = [{"id": 1, "status": "paid"}, {"id": 2}, {"id": 3, "status": "void"}]
for row in rows:
if "status" not in row:
continue
if row["status"] != "paid":
continue
print("processing", row["id"]) # processing 1
Open this example in the editor and change it. Nothing you type is saved.
Three guard clauses at one level of indentation, rather than a pyramid.
while, and the walrus
Use while when the number of iterations is not known in advance - reading until a sentinel, retrying until success, converging on an answer.
import random
random.seed(7)
attempts = 0
while True:
attempts += 1
roll = random.randint(1, 6)
if roll == 6:
break
if attempts >= 20:
print("gave up")
break
print(f"took {attempts} attempt(s)")
Open this example in the editor and change it. Nothing you type is saved.
The walrus operator assigns and tests in one step, which is what makes read-until-empty loops read well:
lines = iter(["first", "second", "", "third"])
while (line := next(lines, None)) is not None:
if not line:
print("(blank)")
continue
print(line)
Open this example in the editor and change it. Nothing you type is saved.
Without the walrus you would either read once before the loop and once at the end of it - duplicating the call - or use while True with a break. The walrus is also useful inside a comprehension, where it lets you compute something once and both test and use it:
values = ["12", "x", "7"]
numbers = [n for v in values if (n := v if v.isdigit() else None) is not None]
print(numbers) # ['12', '7']
Open this example in the editor and change it. Nothing you type is saved.
match, for shape rather than value
match is not a switch statement. A switch compares a value against constants; match destructures a shape.
def describe(event):
match event:
case {"type": "click", "x": x, "y": y}:
return f"click at {x},{y}"
case {"type": "key", "key": key} if key.isalpha():
return f"letter {key}"
case {"type": "key", "key": key}:
return f"key {key}"
case [first, *rest] if rest:
return f"a list starting with {first} and {len(rest)} more"
case []:
return "an empty list"
case str() as text:
return f"the string {text!r}"
case _:
return "something else"
print(describe({"type": "click", "x": 10, "y": 20})) # click at 10,20
print(describe({"type": "key", "key": "a"})) # letter a
print(describe({"type": "key", "key": "1"})) # key 1
print(describe([1, 2, 3])) # a list starting with 1 and 2 more
print(describe([])) # an empty list
print(describe("hello")) # the string 'hello'
print(describe(3.5)) # something else
Open this example in the editor and change it. Nothing you type is saved.
Four features are doing the work there. Mapping patterns match a dict that has at least those keys and bind the values. Sequence patterns destructure, with *rest absorbing the remainder. Guards (if key.isalpha()) add a condition that is not about shape. And class patterns like str() match by type.
One trap, and it is a sharp one:
LIMIT = 10
value = 5
match value:
case LIMIT: # does NOT compare to 10
print("at the limit")
# This REBINDS LIMIT to 5 and always matches.
print(LIMIT) # 5
Open this example in the editor and change it. Nothing you type is saved.
A bare name in a case is a capture pattern: it binds, it does not compare. To compare against a constant it must be a dotted name - case constants.LIMIT: - or you must use a guard: case n if n == LIMIT:. This is the single most common match mistake, and because it always matches, it silently swallows every later case.
A worked example
from dataclasses import dataclass
@dataclass
class Transaction:
kind: str
amount_pence: int
account: str
LEDGER = [
Transaction("deposit", 150000, "current"),
Transaction("withdraw", 2500, "current"),
Transaction("transfer", 50000, "savings"),
Transaction("withdraw", 999999, "current"),
Transaction("fee", 350, "current"),
Transaction("deposit", 7500, "savings"),
]
balances = {"current": 0, "savings": 0}
rejected = []
for index, tx in enumerate(LEDGER, start=1):
# A guard clause, rather than nesting everything that follows.
if tx.account not in balances:
rejected.append((index, "unknown account"))
continue
match tx:
case Transaction(kind="deposit", amount_pence=amount):
balances[tx.account] += amount
case Transaction(kind="withdraw", amount_pence=amount) if amount <= balances[tx.account]:
balances[tx.account] -= amount
case Transaction(kind="withdraw", amount_pence=amount):
rejected.append((index, f"insufficient funds for {amount}"))
case Transaction(kind="transfer", amount_pence=amount) if amount <= balances["current"]:
balances["current"] -= amount
balances[tx.account] += amount
case Transaction(kind="fee", amount_pence=amount):
balances[tx.account] -= amount
case _:
rejected.append((index, f"unhandled kind {tx.kind!r}"))
for account, pence in balances.items():
print(f"{account:<10} {pence / 100:>10,.2f}")
print()
for index, reason in rejected:
print(f"line {index}: {reason}")
# current 971.50
# savings 575.00
#
# line 4: insufficient funds for 999999
# The search loop, with the else that means "no break".
for tx in LEDGER:
if tx.amount_pence > 1000000:
print("found a large transaction")
break
else:
print("no transaction over 10,000.00")
Open this example in the editor and change it. Nothing you type is saved.
Notice what match bought: the deposit, the successful withdrawal, the failed withdrawal and the transfer are four cases distinguished by a mixture of kind and condition, and each reads as one line. The if/elif version of that needs nested conditions and repeats tx.amount_pence in every branch.
When it goes wrong
| Symptom | Cause |
|---|---|
A zip quietly dropped rows |
The shorter sequence ends it; pass strict=True |
Every match took the first case |
A bare name in case captures; it does not compare |
else on a loop ran when you did not expect |
It means "no break", not "otherwise" |
| Items were skipped while removing in a loop | Mutating a list while iterating it; build a new one |
A while never terminated |
The condition's variable is never updated inside |
range(len(x)) broke on a generator |
Generators have no length; iterate directly |
| A counter started at 0 when you wanted 1 | enumerate(x, start=1) |
continue skipped the increment in a while |
The increment belongs before the continue |
That last one is worth a line of code, because it is a genuine infinite loop:
i = 0
while i < 5:
if i == 2:
i += 1 # without this, continue loops forever at 2
continue
print(i)
i += 1
Open this example in the editor and change it. Nothing you type is saved.
A for loop cannot have that bug, which is one more reason to prefer it.
A check you can run
LIMIT = 10
for value in [5, 10, 15]:
match value:
case LIMIT:
print(value, "matched LIMIT")
print("LIMIT is now", LIMIT)
Open this example in the editor and change it. Nothing you type is saved.
Every value "matches LIMIT", and LIMIT ends up as 15.
Then add case _: print(value, "did not match") underneath and run it again. It will not even compile: SyntaxError: name capture 'LIMIT' makes remaining patterns unreachable. Python is telling you, in as many words, that your constant is a capture pattern - which is a good deal more help than the silent always-match you get when there is nothing after it.
The fix is a guard, case n if n == LIMIT:, or a dotted name such as case settings.LIMIT:.