4  Control Flow

NoteCore idea

Python’s control flow is built from three constructs — if, for, and while — plus two loop-control keywords (break, continue) and a small set of helpers (enumerate, zip, range) that make loops Pythonic.

In this chapter you will learn to:

  1. Branch with if / elif / else and the chained-comparison form.
  2. Iterate with for over any iterable, and use range, enumerate, and zip.
  3. Loop with while and exit early with break / skip with continue.
  4. Use the ternary expression x if cond else y for one-line conditionals.

4.1 if, elif, else

The most basic decision your program makes: “is this value negative, zero, or positive?” if / elif / else is the universal three-way (or n-way) branch.

def classify(n):
    if n < 0:
        return "negative"
    elif n == 0:
        return "zero"
    else:
        return "positive"

[classify(x) for x in (-3, 0, 5)]
['negative', 'zero', 'positive']
  • if n < 0: checks the first condition. If true, run the indented body and skip the rest.
  • elif n == 0: (“else if”) is checked only when the previous condition was false. You can chain as many elifs as you need.
  • else: runs when no preceding condition matched. It’s optional.
  • The list comprehension [classify(x) for x in (-3, 0, 5)] calls the function on three test cases.

The general rule: if opens a chain, elif adds branches, else is the catch-all — Python evaluates conditions top-down and runs exactly one body. Conditions use ==, !=, <, >, <=, >=. Combine with and, or, not. Python’s chained comparisons are unique and read naturally:

def in_range(n):
    return 0 < n < 100   # not n > 0 and n < 100

[in_range(50), in_range(0), in_range(100), in_range(-5)]
[True, False, False, False]

The ternary expression is the one-line form:

n = 7
parity = "even" if n % 2 == 0 else "odd"
parity
'odd'

Reading right-to-left in Python idiom: “this is 'even' if n % 2 == 0, else 'odd'.” The order is unusual — the value comes first, the condition in the middle, the fallback at the end. The full shape is <value-if-true> if <condition> else <value-if-false>, and the whole thing is an expression (it returns a value), so it can sit on the right-hand side of an assignment, inside an f-string, in a list comprehension. For n = 7, n % 2 is 1 (odd), so the condition is false and the result is "odd". Reach for the ternary when both branches are short; if either branch needs more than a small expression, an if/else statement reads better.

4.2 for over any iterable

In C or Java you’d write for (int i = 0; i < items.length; i++) { items[i] ... }. Python’s for is different — it does not iterate by index, it iterates over the items themselves. You almost never need an index at all.

fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)
apple
banana
cherry
  • for fruit in fruits: binds fruit to each element of the list in turn.
  • The body runs once per element; on the next pass fruit is rebound to the next item.
  • No counter to maintain, no off-by-one to risk.

range(start, stop, step) produces integers — useful when you really need actual numbers (not just to index a list):

list(range(5)), list(range(2, 8)), list(range(0, 10, 2)), list(range(10, 0, -1))
([0, 1, 2, 3, 4],
 [2, 3, 4, 5, 6, 7],
 [0, 2, 4, 6, 8],
 [10, 9, 8, 7, 6, 5, 4, 3, 2, 1])
  • range(5) produces 0, 1, 2, 3, 4stop is exclusive, start defaults to 0.
  • range(2, 8) produces 2, 3, 4, 5, 6, 7.
  • range(0, 10, 2) adds a step of 2, producing the even numbers under 10.
  • range(10, 0, -1) counts down — negative step works too.

Two helpers turn the cases where you do need extras (an index, or a parallel list) into clean code:

for i, fruit in enumerate(fruits, start=1):
    print(i, fruit)
1 apple
2 banana
3 cherry
  • enumerate(fruits, start=1) yields (1, "apple"), (2, "banana"), (3, "cherry") — pairs of index and item.
  • i, fruit = pair happens implicitly via tuple unpacking on the loop variable.
  • start=1 shifts the count to begin at 1 instead of the default 0 — handy for human-readable output.
names = ["Alice", "Bob", "Carol"]
scores = [95, 87, 92]
for name, score in zip(names, scores):
    print(f"{name}: {score}")
Alice: 95
Bob: 87
Carol: 92
  • zip(names, scores) walks two iterables in lockstep, yielding ("Alice", 95), ("Bob", 87), ("Carol", 92).
  • The loop unpacks each pair into name and score.

The general rule: for x in xs is the default. Reach for enumerate only when you need the index, zip only when you walk parallel sequences. zip stops at the shortest iterable. enumerate gives (index, value) pairs.

Under the hood, for x in iterable: desugars into a small protocol: ask the object for an iterator, then keep calling next() on it until it signals exhaustion.

flowchart TD
    A["for x in iterable:"] --> B["iter(iterable)<br/>calls __iter__"]
    B --> C[iterator object]
    C --> D["next(iterator)<br/>calls __next__"]
    D --> E{value or<br/>StopIteration?}
    E -- value --> F["bind to x<br/>run loop body"]
    F --> D
    E -- StopIteration --> G[loop ends]

Anything implementing __iter__ works in for. That single rule is why lists, strings, files, dicts, sets, and your own classes all loop with the same syntax. The deep dive is Chapter 29.

4.3 while

for works when you have an iterable in hand. while is for the other case — looping until a condition becomes false, when you don’t know in advance how many iterations that takes.

count = 0
total = 0
while total < 50:
    count += 1
    total += count
count, total
(10, 55)
  • count and total are initialised before the loop — the condition needs them to exist.
  • while total < 50: is checked before each pass. While true, the body runs.
  • Each pass increments count and adds it to total — the running sum 1+2+3+….
  • When total first reaches or exceeds 50, the condition fails and the loop exits.

The general rule: while condition: keeps going as long as the condition is true; the body must eventually flip it to false or you have an infinite loop. If the condition is initially false, the body never runs.

4.4 break and continue

Two keywords let you reshape a loop’s flow without an extra flag variable: break to bail out entirely, continue to skip ahead to the next item. They’re the difference between “find me the first match” and “process all the valid ones”.

# First even number
for n in [1, 3, 5, 6, 7, 8]:
    if n % 2 == 0:
        first = n
        break
first
6
  • The loop walks the list left to right.
  • On n = 6 the if triggers: first is bound, then break immediately exits the loop — 7 and 8 are never inspected.
  • This is the canonical “find the first one that matches” idiom.
# Skip negatives, sum positives
total = 0
for x in [3, -1, 4, -2, 5]:
    if x < 0:
        continue
    total += x
total
12
  • For each item, the if checks for negatives.
  • continue jumps straight to the next iteration, skipping the total += x line for negative values.
  • Positive values fall through and accumulate.

The general rule: break exits the innermost loop; continue skips the rest of the current iteration but stays in the loop. Both are clearer than wrapping the body in a flag-controlled if.

4.5 Walrus assignment

The walrus operator := (Python 3.8+) assigns and returns a value in a single expression. Use it where you’d otherwise call something twice — once to test it, once to use the result.

import re

lines = ["score: 95", "skip", "score: 87", "score: 72"]
scores = []
for line in lines:
    if (m := re.match(r"score: (\d+)", line)):
        scores.append(int(m.group(1)))
scores
[95, 87, 72]

Reading the regex piece by piece: re.match(pattern, line) tries to match pattern against the start of line. The pattern r"score: (\d+)" looks for the literal text score: followed by one or more digits, with the parentheses defining a capturing group — once the match succeeds, m.group(1) returns whatever the first parenthesised subpattern captured (here the digit string). On a non-match re.match returns None, which is falsy, so the if skips that line. The walrus saves the regex call: without it, you’d either assign on a separate line first or run the regex twice (once to test, once to extract).

The same pattern collapses a while loop that processes input until exhausted:

chunks = iter(["one\n", "two\n", "three\n", ""])
out = []
while (chunk := next(chunks)):
    out.append(chunk.rstrip())
out
['one', 'two', 'three']

Two built-ins are at work here. iter(iterable) turns any iterable (list, tuple, file, …) into an iterator — an object that remembers its position. next(iterator) advances by one element and returns it. The walrus binds that result to chunk and the while condition checks its truthiness — when the iterator yields "" (the empty string, which is falsy), the loop exits before trying to do anything with it. Don’t reach for := everywhere — it’s earned its place when it removes a duplicate call.

4.6 A note on the match statement

Python 3.10 added a more powerful pattern-matching statement, match / case. It’s the right tool for multi-way dispatch on the shape of data, not just on a value. The deep treatment is in Chapter 30; here is a one-liner so you’ve seen it:

def describe(point):
    match point:
        case (0, 0):
            return "origin"
        case (0, y):
            return f"on Y axis at {y}"
        case (x, 0):
            return f"on X axis at {x}"
        case (x, y):
            return f"at ({x}, {y})"

[describe((0, 0)), describe((0, 5)), describe((3, 0)), describe((3, 4))]
['origin', 'on Y axis at 5', 'on X axis at 3', 'at (3, 4)']
  • match point: introduces the value to be matched; each case pattern is tried in order, top to bottom.
  • case (0, 0): matches a 2-tuple containing exactly two zeros — literal match.
  • case (0, y): matches any 2-tuple whose first element is 0, and binds the second to a fresh name y for use in the body.
  • case (x, 0): is the mirror — first slot binds, second must be 0.
  • case (x, y): is the catch-all — any 2-tuple binds both slots.

The general rule: case patterns destructure shape and bind names in one step. For everyday branching, prefer plain if / elif / else — it’s clearer.

TipWhy this matters

The Pythonic loop iterates items, not indices. enumerate and zip cover the cases where you do need the index or two parallel sequences. Once you stop reaching for range(len(x)), your code reads more like the prose description of the algorithm — and bugs from off-by-one indexing simply stop happening.

4.7 Going deeper

The mechanism that lets for work on any iterable — the iterator protocol — is one of the central topics of the book. The deep treatment, including iter(), next(), __iter__, and __next__, is Chapter 29. Pattern matching is in Chapter 30.

4.8 Build: a class report card

Given a list of (name, score) records, we’ll print a ranked roster with letter grades and a class average. Every construct from the chapter — if/elif, for, enumerate, zip, continue, ternary — earns its place.

Step 1: a letter-grade function. A clean three-way (well, five-way) elif chain:

def letter(score):
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    elif score >= 60:
        return "D"
    else:
        return "F"

[letter(s) for s in (95, 82, 71, 60, 45)]
['A', 'B', 'C', 'D', 'F']

The elif chain is checked top-to-bottom; the first condition that matches wins. Order matters: score >= 70 is only reached if score >= 90 and score >= 80 were both false, so we know score < 80 here without writing it.

Step 2: a ranked roster. Sort the students from highest score to lowest, then print rank, name, score, grade. enumerate(start=1) gives 1-based ranks; an f-string formats the columns:

students = [("Alice", 95), ("Bob", 72), ("Carol", 88),
            ("Dave", 0), ("Eve", 60)]

ranked = sorted(students, key=lambda pair: pair[1], reverse=True)
for rank, (name, score) in enumerate(ranked, start=1):
    print(f"{rank:>2}. {name:<8} {score:>3}  {letter(score)}")
 1. Alice     95  A
 2. Carol     88  B
 3. Bob       72  C
 4. Eve       60  D
 5. Dave       0  F

sorted(..., key=..., reverse=True) sorts by the score (the second element of each tuple); lambda pair: pair[1] is a tiny throwaway function — we’ll cover it properly in Chapter 6. The loop variable (name, score) destructures the tuple inside enumerate’s pair, so the rank, name, and score fall into the right slots in one step.

Step 3: average score, skipping the absent. A score of 0 here means “didn’t sit the exam” — we want to exclude those from the class average. continue is exactly the tool:

total = 0
counted = 0
for name, score in students:
    if score == 0:
        continue
    total += score
    counted += 1

average = total / counted if counted else 0
print(f"Class average: {average:.1f}  (n={counted})")
Class average: 78.8  (n=4)

continue jumps to the next iteration without running the rest of the body — the absent students don’t contribute to total or counted. The ternary expression total / counted if counted else 0 guards against division-by-zero with a one-line conditional: if counted is truthy (non-zero), divide; otherwise return 0.

The build is one screen of code that exercises every construct in the chapter — if/elif, for, enumerate, zip-style pair destructuring, continue, the ternary, and the truthiness rule on counted.

4.9 Exercises

  1. FizzBuzz. For numbers 1–20: print “Fizz” if divisible by 3, “Buzz” if divisible by 5, “FizzBuzz” if both, otherwise the number. Write it twice — once with nested if, once with chained elif.

  2. enumerate start. Use enumerate(items, start=1) to print 1-indexed labels. Why is the default 0, and when would you prefer 1?

  3. zip with *. Given pairs = [(1, "a"), (2, "b"), (3, "c")], use zip(*pairs) to “transpose” it. Predict the output.

  4. Replace range(len(...)). Find a piece of your existing code that does for i in range(len(x)). Rewrite it with enumerate (if you need the index) or just for item in x.

  5. Early break. Write find_first(predicate, iterable) that returns the first item satisfying predicate, or None if none does. Use for and break.

4.10 Summary

if, for, while, plus break, continue, enumerate, zip, and range. With these you can express almost every algorithm. The next chapter, Chapter 5, fills in the data structures these loops typically iterate over: lists, tuples, dictionaries, and sets.