Making Decisions and Repeating Work

Article · 11:00

Indentation is the block structure: there are no braces, and the colon at the end of a line is what announces that a block follows. Iterating over items rather than indices, and the else that belongs to a loop rather than to an if.

This lesson has 16 runnable examples. Press Try yourself! under any of them to open it in the editor.

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

PY Example 1 9 lines
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:

PY Example 2 7 lines
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.

PY Example 3 10 lines
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.

PY Example 4 7 lines
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

PY Example 5 8 lines
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

PY Example 6 9 lines
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":

PY Example 7 5 lines
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:

PY Example 8 8 lines
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.

PY Example 9 13 lines
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:

PY Example 10 7 lines
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:

PY Example 11 3 lines
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.

PY Example 12 25 lines
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:

PY Example 13 8 lines
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

PY Example 14 61 lines
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:

PY Example 15 7 lines
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

PY Example 16 6 lines
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:.