S006 · Control Flow — if/else, loops, comprehensions
The three shapes of Python control flow every dev needs to reach for on reflex — branches, loops, comprehensions — with the rules for when each one is right and when it silently becomes unreadable.
🎯 Write Python that reads well: pick between if/elif chain vs match, for vs comprehension vs generator, and know when a one-liner has gone too far.
Why this session exists
Control flow is where Python's ‘readability first’ philosophy either sings or breaks. A well-written comprehension replaces 6 lines of imperative loop with 1 self-documenting line. A badly written comprehension is a triple-nested for with two conditions and a walrus operator — and you can't read it the next morning. This session gives you the taste to tell the difference, plus a mental menu of the four control-flow shapes and the three iteration shapes so you never fumble for syntax again.
- Pick between if/elif/else, ternary, and match/case for any branching problem, on reflex.
- Write list, dict, and set comprehensions — AND know when a plain for-loop is more readable.
- Reach for the right iteration tool: `for`, `while`, `enumerate`, `zip`, generator expression, `itertools`.
- Recognise the ‘loop that should have been a comprehension’ (and the reverse) in any code review.
- Explain lazy evaluation and predict the memory + time footprint of a generator vs a list.
Prerequisites
- S005 — Python Variables & Types — you need to know lists, tuples, dicts, sets.
- S001 — Dev Environment — running Python 3.10+ (for
match).
(a) Intuition · 5 min
Cooking has a small set of shapes: chop, sauté, roast, simmer. Every dish is a sequence of them. A cook who tries to sauté a whole chicken produces the same disaster as a cook who tries to roast a delicate fish for 20 seconds. The ingredient tells you which shape.
Control flow works the same. Python gives you a menu — if/elif/else, ternary, match/case, for, while, comprehensions, generators. Each has a shape it fits perfectly. Picking the wrong shape makes the code weird even when it works.
The ingredient that tells you which shape to use is the intent of the code. Are you branching on 2 outcomes? Use if. Are you exhaustively matching on a structure? Use match. Are you transforming every item of a list? Use a comprehension. Are you iterating with side effects? Use a plain for.
The senior-dev skill isn't memorising syntax — it's matching intent to shape on reflex.
The four control-flow shapes
- if/elif/else — 2 to ~4 branches, each testing an arbitrary condition. The default for boolean-y decisions.
- ternary (x if cond else y) — a SINGLE value depending on one condition. Only when it fits on one short line.
- match/case — 5+ branches OR pattern-matching against structure (tuple shape, dict keys, isinstance-and-attributes). Python 3.10+.
- dict dispatch — a mapping from key to handler function. When branches are homogeneous callables. Beats a giant elif chain.
The three iteration shapes
- for x in items: — imperative loop with side effects (print, log, mutate external state, break early). The workhorse.
- [f(x) for x in items if cond] — comprehension. Builds a new list. Use when you're TRANSFORMING and want the result.
- (f(x) for x in items) — generator expression. Streams items one at a time. Use for large inputs, chains, or when you'll consume once.
A quick history
- 1991Python 0.9.0Ships with `if`, `while`, `for` — Guido keeps the branch/loop syntax minimal on purpose.
- 2000List comprehensions (2.0)Borrowed from Haskell. Transforms Python's iteration style forever.
- 2001Generators (2.2)`yield` keyword. Lazy iteration; the foundation of asyncio a decade later.
- 2018The walrus operator (3.8)PEP 572 introduces `:=` for assignment expressions. Divisive; useful in ~5% of cases.
- 2021Structural pattern matching (3.10)`match/case` arrives — not just a switch, real destructuring. Changes how Python parses trees, JSON, AST.
(b) Visual walkthrough · 15 min
The decision tree for picking a control-flow shape
Memorise the tree. Every ‘which one do I use?’ question resolves in <3 seconds.
Comprehension vs for-loop — the same problem, three ways
Imperative, familiar
- squares = []
- for n in range(10):
- if n % 2 == 0:
- squares.append(n*n)
- 6 lines, mutates state
Declarative, self-doc
- squares = [n*n for n in range(10) if n % 2 == 0]
- 1 line
- Reads left-to-right: WHAT then FROM WHAT then WHERE
- Faster too — the loop runs in C
Lazy, memory-friendly
- squares = (n*n for n in range(10) if n % 2 == 0)
- No list built
- sum(n*n for n in range(1_000_000) if n % 2 == 0) — O(1) memory
- Consumed once — can't restart
match/case — not a switch, a destructurer
def describe(msg):
match msg:
case {"type": "ping"}: # dict shape
return "pong"
case {"type": "user", "name": name, "age": age} if age >= 18:
return f"adult user {name}"
case {"type": "user", "name": name}: # binding
return f"minor user {name}"
case [x, y, *rest]: # list shape
return f"list starting {x},{y} + {len(rest)} more"
case Point(x=0, y=0): # class pattern
return "origin"
case _: # fallthrough
return "unknown"The itertools cheat sheet
Six itertools functions that replace hand-rolled loops
The mental model to hold
"A for loop in Python iterates over indices, and for x in collection is just sugar for for i in range(len(collection)). So modifying the list while looping is fine — I'm just changing the data as I go."
Python's for is built on the iterator protocol, not on indices. It calls iter(obj) once, then next() repeatedly until StopIteration. The loop holds a live cursor into the object, so mutating the object underneath it silently skips or repeats elements.
Because the index model produces correct predictions for the common case. Looping over an unchanged list gives identical results either way, so nothing contradicts you for a long time. The model breaks the first time you meet something that has no indices at all — a file handle, a generator, a dict, a database cursor — and the first time you remove elements while iterating. The list iterator really does hold an integer position, so deleting element i shifts everything left while the cursor moves right: you skip exactly every other match. The bug looks like "my filter only removed half the items", which is maddening precisely because half of it worked.
Both of these are silent wrong answers, not exceptions:
xs = [1, 2, 2, 2, 3]
for x in xs:
if x == 2:
xs.remove(x)
print(xs) # [1, 2, 3] <- one '2' survived
# the iterator is a real object with state:
it = iter([1, 2, 3])
print(next(it), next(it)) # 1 2
for y in it: print(y) # 3 only - the cursor moved
# correct: build a new list, don't mutate under the cursor
xs = [x for x in xs if x != 2]Why does Python have an else clause on for and while, and why does it run when the loop doesn't break? This reads like a naming mistake — it isn't.
- 1The canonical use of a loop with
breakis a search: scan until you find something, then stop early.forced by · continuing after a hit is wasted work, andbreakis how you express "found it" - 2Every search therefore has two distinct exits — found (broke out) and exhausted (ran off the end) — and they usually need different follow-up code.forced by · "not found" is a real outcome that needs handling, not an error
- 3Without language support you must encode which exit happened in a flag: set
found = Truebefore thebreak, then test it after the loop.forced by · after the loop ends, the loop body's control flow is no longer visible - 4That flag is pure bookkeeping — it carries one bit the interpreter already knows, and it is a classic bug site: forget to reset it, set it in the wrong branch, or shadow it.forced by · manually mirroring state the runtime already tracks is always redundant and always fragile
- 5So the language exposes the bit directly as a clause that runs on the exhausted exit only.forced by · the only two exits are break and exhaustion, so a clause attached to one fully determines the other
Therefore for...else is precisely "the search failed" — it would have been clearer named nobreak. The mental substitution that makes it instantly readable: read else as "if no break".
And note what this predicts: the same clause exists on while (same two exits) and on try (runs when no exception was raised) — in every case, else means "the normal path completed without the escape hatch firing". Once you see that unifying rule, try/except/else/finally stops being four arbitrary keywords.
Don't picture for as a counter walking an array. Picture the loop asking a source object, over and over, "give me the next one" — and the source deciding what that means. A list hands back its next slot; a file hands back its next line without loading the file; a generator computes the next value on demand; a database cursor fetches the next page over the network.
The loop is identical in all four cases because it only knows two operations: iter() to get a cursor, next() to advance it. That uniformity is why the same for works over a 10-element list and a 200 GB log stream.
- Iterators are single-use and stateful. Once exhausted they stay exhausted — this is why a second loop over the same
zip()ormap()yields nothing. - Never mutate a collection you are iterating. Build a new one (comprehension) or iterate a copy (
list(xs)) or collect indices and delete in reverse. - Prefer
enumerate,zip, and comprehensions over index arithmetic — they make off-by-one errors unrepresentable rather than merely unlikely. break/continue/elseonly bind to the innermost loop. Deep nesting that needs to escape several levels is a signal to extract a function and usereturn.
Fire this model the moment you see: a filter that removed only half the matches · a generator that appears empty on second use · RuntimeError: dictionary changed size during iteration · a loop that loads a whole file to process it line by line · nested loops three deep with a flag variable.
You need to transform a large collection. Do you write an explicit loop, a comprehension, or a lazy generator?
next() or any() can stop the work earlylen()-able; exceptions surface at consumption time far from the code that caused them, which makes stack traces confusingDefault to a comprehension for small in-memory transforms, a generator the moment data size stops being obviously bounded, and an explicit loop as soon as the body needs statements rather than an expression. The failure mode to avoid is the clever nested comprehension with two fors and two ifs — it is not faster than the loop it replaced and no one, including you in a month, can read it.
For data work the generator/comprehension boundary is the one that actually costs money: [transform(r) for r in rows] over a query result quietly pins the full result set in RAM, while the generator form streams it. On a laptop both work; at production volume only one does.
(c) Hands-on · 25 min
Save as s006_control_flow.py, run, and predict each output BEFORE running.
"""s006_control_flow.py — the shapes of Python control flow."""
from __future__ import annotations
from itertools import chain, groupby, islice, product
from operator import itemgetter
# =========================
# Part 1 · Branching menu
# =========================
print("--- Part 1 ---")
def grade_if(score: int) -> str:
if score >= 90: return "A"
elif score >= 75: return "B"
elif score >= 60: return "C"
else: return "F"
def grade_dict(score: int) -> str:
# Better when you have many uniform thresholds — but this pattern gets awkward
# for ranges. Keep if/elif for range decisions.
return {True: "A"}[True] if score >= 90 else "…" # illustration only
def grade_match(msg: dict) -> str:
match msg:
case {"kind": "quiz", "score": s} if s >= 90: return f"quiz A ({s})"
case {"kind": "quiz", "score": s}: return f"quiz other ({s})"
case {"kind": "essay", "words": w}: return f"essay {w} words"
case _: return "unknown"
print(grade_if(88))
print(grade_match({"kind": "quiz", "score": 92}))
print(grade_match({"kind": "essay", "words": 500}))
print(grade_match({"kind": "video"}))
# Ternary for a SINGLE value, one line
def parity(n: int) -> str: return "even" if n % 2 == 0 else "odd"
print(parity(4), parity(7))
# Dict-dispatch when branches are handlers
HANDLERS = {
"ping": lambda: "pong",
"hello": lambda: "hi",
"quit": lambda: None,
}
def dispatch(cmd: str):
return HANDLERS.get(cmd, lambda: f"unknown: {cmd}")()
print(dispatch("ping"), dispatch("foo"))
# =========================
# Part 2 · Comprehensions — the sweet spot
# =========================
print("\n--- Part 2 ---")
nums = range(20)
evens = [n for n in nums if n % 2 == 0]
squares_of_odds = [n*n for n in nums if n % 2]
print(evens)
print(squares_of_odds)
# Dict comprehension
word_lens = {w: len(w) for w in ["apple", "banana", "kiwi"]}
print(word_lens)
# Set comprehension — dedup while transforming
unique_first_letters = {w[0].upper() for w in ["apple", "avocado", "banana"]}
print(unique_first_letters)
# Nested comprehension — allowed, but STOP if it gets deeper than 2 levels
grid = [[r * 3 + c for c in range(3)] for r in range(3)]
print(grid)
# =========================
# Part 3 · Iteration idioms every Python dev knows
# =========================
print("\n--- Part 3 ---")
names = ["ada", "linus", "grace"]
# enumerate — index + value
for i, name in enumerate(names, start=1):
print(f" {i}. {name}")
# zip — parallel iteration
scores = [92, 88, 100]
for name, score in zip(names, scores, strict=True): # 3.10+: strict=True asserts equal lengths
print(f" {name}: {score}")
# reversed / sorted
for name in sorted(names, key=len):
print(" ", name)
# =========================
# Part 4 · Generators — lazy is powerful
# =========================
print("\n--- Part 4 ---")
def read_lines_lazy(n: int):
"""Yields simulated lines from a huge file."""
for i in range(n):
yield f"line {i}: some content"
# List: builds ALL lines in memory
big_list = list(read_lines_lazy(100))
# Generator: streams, one line at a time
big_gen = read_lines_lazy(100)
# Pipe-style processing — the reason generators shine
pipeline = (
line.upper()
for line in big_gen
if "5" in line
)
first_five = list(islice(pipeline, 5))
print(first_five)
# Consume the pipeline in constant memory
total_chars = sum(len(line) for line in read_lines_lazy(1_000_000))
print(f"processed 1M lines in constant memory; total chars = {total_chars}")
# =========================
# Part 5 · itertools that replace loops
# =========================
print("\n--- Part 5 ---")
# chain — flatten
flat = list(chain([1, 2, 3], [4, 5], [6]))
print("chain:", flat)
# product — cartesian
combos = list(product("AB", [1, 2]))
print("product:", combos)
# groupby — cluster CONSECUTIVE keys
data = [("a", 1), ("a", 2), ("b", 3), ("a", 4)]
# Note: groupby only groups consecutive; sort first for global grouping.
for key, group in groupby(sorted(data, key=itemgetter(0)), key=itemgetter(0)):
print(f" {key}: {[v for _, v in group]}")
# =========================
# Part 6 · When comprehensions go too far
# =========================
print("\n--- Part 6 ---")
items = [{"name": "a", "tags": ["x", "y"]}, {"name": "b", "tags": ["y", "z"]}]
# BAD — please don't
bad = [t for i in items for t in i["tags"] if t != "z" if len(t) == 1]
# GOOD — same output, readable
good = []
for item in items:
for tag in item["tags"]:
if tag != "z" and len(tag) == 1:
good.append(tag)
print("bad :", bad)
print("good:", good)
print("^^ rule of thumb: > 1 filter or > 1 nesting → promote to a for-loop")What each block teaches
Anatomy of the exercises
Given this input:
records = [
{"user": "ada", "logins": [200, 401, 500, 200]},
{"user": "linus", "logins": [200, 200, 500]},
{"user": "grace", "logins": [401, 401]},
]Produce: a list of tuples (user, count_of_500s) for users with at least one 500, sorted by count descending. Write it three ways: (1) a plain for-loop, (2) a mixed loop + comprehension for the counting, (3) a single comprehension. Rank the three by readability. Discuss with a friend or write your ranking in a comment.
(d) Production reality · 15 min
A dev writes:
rows = (parse(line) for line in open("data.csv"))
count = sum(1 for _ in rows)
first_five = list(islice(rows, 5)) # empty!The generator was consumed by sum; the second read yields nothing. Bug shows up in production when the CSV grows past a size where the dev used to run tiny tests.
rows = list(...) (if the file fits in memory), or re-open the file / re-create the generator for each pass. Better: pass the generator through one pipeline that computes both count and preview in a single scan (e.g. itertools.tee, or accumulate manually).list()) or design a single pass that produces everything you need.Someone replaced if user_id in valid_ids: (where valid_ids was a list of 50k IDs) with a comprehension. Innocent. Nobody noticed that every membership check was O(n), and the request handler called it in a nested loop — O(n²) per request.
Under load, P99 went from 40 ms to 300 ms. Two weeks of finger-pointing before someone read the code.
valid_ids = set(valid_ids). One line. P99 back to 40 ms in a single deploy. Bonus: added a linter rule to flag `in a_list` where the list is longer than N in hot paths.x in a_list is O(n); x in a_set is O(1). Always check membership against a set when the same collection is queried more than once.lines = [x for line in f if (x := parse(line)) is not None]. Cool trick — filters and captures in one pass. Reviewer bounces the PR: ‘unreadable, split it’.lines = []
for line in f:
parsed = parse(line)
if parsed is not None:
lines.append(parsed)Or, with a generator helper: lines = [p for p in (parse(l) for l in f) if p is not None]. Better yet: define def parsed_lines(f): … and hide the walrus inside.
:=) is a power tool. Use it when it makes intent clearer, not when it makes a one-liner shorter. If a reviewer had to read the line twice, it should be two lines.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- When do you use a comprehension vs a for-loop? (one rule)
- What's the difference between a list and a generator expression? (memory + reuse)
- When is
match/casethe right tool? (name the two triggers)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.