Search Tech Journey

Find topics, journeys and posts

6-month learning plan6 / 130
back to blog
pythonbeginner 50m read

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.

🧠SoftwareM01 · Python Foundations· Session 006 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

Control flow is a menu of shapes, not a language quirk
🌍 Real world

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.

💻 Code world

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

Every branch you'll ever write fits one of these four
  • 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

Every ‘do this for each item’ you'll ever write
  • 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

  1. 1991
    Python 0.9.0
    Ships with `if`, `while`, `for` — Guido keeps the branch/loop syntax minimal on purpose.
  2. 2000
    List comprehensions (2.0)
    Borrowed from Haskell. Transforms Python's iteration style forever.
  3. 2001
    Generators (2.2)
    `yield` keyword. Lazy iteration; the foundation of asyncio a decade later.
  4. 2018
    The walrus operator (3.8)
    PEP 572 introduces `:=` for assignment expressions. Divisive; useful in ~5% of cases.
  5. 2021
    Structural 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

Plain for-loop

Imperative, familiar

  • squares = []
  • for n in range(10):
  • if n % 2 == 0:
  • squares.append(n*n)
  • 6 lines, mutates state
List comprehension

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
Generator expression

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

chain(*iterables)
Flatten several iterables into one stream. `chain([1,2], [3,4]) → 1,2,3,4`.
combine
zip_longest(a, b, fillvalue=…)
Like zip but doesn't stop at the shorter one. Uses fillvalue for the gap.
combine
islice(it, start, stop, step)
Lazy slicing of any iterable (including generators, which don't support [start:stop]).
slice
groupby(iterable, key=…)
Cluster CONSECUTIVE items with the same key. sort first if you want global groups.
group
product(a, b, ...)
Cartesian product. `product([1,2],[a,b]) → (1,a),(1,b),(2,a),(2,b)`.
combine
accumulate(iterable, func=operator.add)
Running totals (or running max/min/whatever). `accumulate([1,2,3,4]) → 1,3,6,10`.
reduce

The mental model to hold


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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]
From first principles
Start with the question

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.

  1. 1
    The canonical use of a loop with break is a search: scan until you find something, then stop early.
    forced by · continuing after a hit is wasted work, and break is how you express "found it"
  2. 2
    Every 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
  3. 3
    Without language support you must encode which exit happened in a flag: set found = True before the break, then test it after the loop.
    forced by · after the loop ends, the loop body's control flow is no longer visible
  4. 4
    That 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
  5. 5
    So 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

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.

Mental modelA loop is a cursor, not a counter

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() or map() 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/else only bind to the innermost loop. Deep nesting that needs to escape several levels is a signal to extract a function and use return.
🔔 Fires when you see

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.

The tradeoff

You need to transform a large collection. Do you write an explicit loop, a comprehension, or a lazy generator?

Explicit for loop
+ you gain arbitrary complexity — multiple statements, early exit, try/except per item, incremental logging and progress; by far the easiest to step through in a debugger
− you pay more lines, mutable accumulator state to get wrong, and the intent (map? filter? reduce?) is not visible until you read the body
pick when the body needs more than one expression, or you need per-item error handling, or the loop has side effects like writing rows
List comprehension
+ you gain intent is declarable at a glance, no accumulator to mismanage, and the interpreter avoids repeated attribute lookup and method-call overhead — measurably faster than the equivalent append loop
− you pay materialises the entire result in memory, cannot early-exit cleanly, and becomes unreadable past one condition and one nesting level
pick when the transform is a single expression, the result fits comfortably in memory, and you want the result as a concrete reusable list
Generator expression / yield
+ you gain constant memory regardless of input size, composes into pipelines, and produces the first result immediately — so a downstream next() or any() can stop the work early
− you pay single-use and not indexable or len()-able; exceptions surface at consumption time far from the code that caused them, which makes stack traces confusing
pick when the input is large or unbounded, or the consumer may not need all of it — streaming a file, paging an API, feeding batches to a model
What a senior engineer actually does

Default 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

Part 1 · pick your branch
if/elif for ranges. match for structural patterns. ternary for one-line value selection. dict dispatch for callable handlers. Match intent to shape.
shape
Part 2 · comprehension power
Reads left-to-right: [WHAT for X in FROM if WHERE]. Dict and set comprehensions use the same shape with {} braces.
comprehension
Part 3 · idioms
enumerate replaces `for i in range(len(...))`. zip pairs sequences. `strict=True` (3.10+) catches length mismatches.
idioms
Part 4 · generator streaming
yield turns a function into an iterator. Composes with islice / any / sum / max in constant memory. This is how pandas alternatives (polars) beat pandas at scale.
lazy
Part 5 · itertools
chain, product, groupby, islice — these replace hand-rolled loops with faster C code AND clearer intent. Learn six, use them forever.
itertools
Part 6 · readability limits
One filter, one source: comprehension is best. Two nested for-clauses OR two if-clauses: promote to a real loop. Save reviewers pain.
taste
Try itRefactor a bad comprehension into a readable loop, then a good comprehension

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.

💡 Hint · If the comprehension has more than one filter OR more than one nested for-clause, it's usually too much. Split it into named helper functions or a plain loop.

(d) Production reality · 15 min

War story Common failure mode · every Python codebaseSilent double-iteration of a generator
🔥 What broke

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.

🧯 The fix
Either 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).
🎓 Lesson to steal
Generators are single-use. If a value is going to be consumed more than once, either materialise it (list()) or design a single pass that produces everything you need.
War story A payments team · 2022P99 latency doubled for a week
🔥 What broke

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.

🧯 The fix
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.
🎓 Lesson to steal
Control flow interacts with data structure choice. 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.
War story Common failure mode · code reviewReviewers rejecting ‘clever’ walrus one-liners
🔥 What broke
A dev submits: 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’.
🧯 The fix
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.

🎓 Lesson to steal
The walrus operator (:=) 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

Control flow shapes bleed into every Python session ahead
S007 · Functions
*args, **kwargs, and the return-early pattern all lean on the branch shapes here.
S008 · Data structures
‘Set for membership’ is the extension of today's O(n²) war story.
S011 · Errors & debugging
try/except is another branch. Use it for EXCEPTIONAL cases, not routine control.
S045 · Pandas / Polars
Vectorised ops are ‘comprehensions on steroids’. Same intent-to-shape mapping.
S065 · asyncio
`async for` and `async comprehensions` extend today's shapes to concurrent iteration.
S095 · Data pipelines
Streaming vs materialised is a system-design decision; today was the local version.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. When do you use a comprehension vs a for-loop? (one rule)
  2. What's the difference between a list and a generator expression? (memory + reuse)
  3. When is match/case the 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.