Dinesh/ Blog
python

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.

26 min readChecking device speech…
Lesson preparation & details

Level: beginner

Python and software engineering · Lesson 6 of 15 ↗
Loading this browser’s progress…

By the end, you should be able to

  • for consumes an iterator; changing collection structure while iterating is hazardous, but overwriting existing list slots can be deliberate and safe

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 22 sections

Review boundary and corrected contracts

for consumes an iterator; changing collection structure while iterating is hazardous, but overwriting existing list slots can be deliberate and safe. for-else means no break, provided control reaches normal completion (not return or exception). Pattern matching class patterns need a defined class.

The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees.

Learning objective: 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 can express a simple transform more directly than an accumulator loop. 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


Pre-read · skim before the session (30 min total)

Further reading: Python Tutorial · More Control Flow Tools

Further reading: When to Use a List Comprehension in Python

Further reading: itertools — Functions Creating Iterators for Efficient Looping

How to use the readings. Read the control-flow tutorial for patterns and loop termination. In itertools, compare consecutive grouping, finite-input product and buffering rather than assuming every recipe is faster. Use the secondary comprehension reading as a readability discussion.

Optional video pointers carried forward from the original lesson. These URLs and inherited titles are navigation aids only; video identity, duration and contents were not reverified in this review. The complete mechanisms and worked answers are in this lesson and the official sources below.


(a) Intuition · 5 min

Control flow is a menu of shapes, not a language quirk

Real: 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

Four useful choices, not an exhaustive grammar

  • if/elif/else — arbitrary conditions, regardless of branch count. 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 — structural patterns such as sequence shape, mapping keys or class attributes, with optional guards. Python 3.10+; five branches is not a language rule.

  • dict dispatch — a mapping from key to handler function. When branches are homogeneous callables. Beats a giant elif chain.

The most common control-flow smell in code reviews

A ternary that spans multiple lines, or nests: x = (a if cond1 else b) if cond2 else c. If you can't read it in one glance, promote it to an if/elif/else. Ternaries are for short, obvious value selection — nothing else. Reviewers will (rightly) call this out.

The three iteration shapes

Three common iteration choices

  • 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

The original Python release in 1991 already had ordinary functions and control flow. Python 2.0 (2000) added list comprehensions; Python 2.2 (2001) introduced generators. Assignment expressions (:=) shipped in Python 3.8 in 2019—not in the year PEP 572 was discussed. Structural pattern matching arrived with Python 3.10 in 2021. Choose these features by the supported interpreter version and the code’s purpose, not by a hierarchy of newer-is-better syntax.

(b) Choosing a branch or iteration shape · 15 min

The decision tree for picking a control-flow shape

The newer syntax did not replace the older loops and branches; it gave them more specialised companions. Start with the job your code must do, then follow the branch that matches the result you need.

The decision tree for picking a control-flow shape
Figure text equivalent — labels and relationships
  • Node “What are you doing?” (square).
  • Node “Branching or iterating?” (diamond).
  • Node “What kind of decision?” (diamond).
  • Node “Producing a new collection?” (diamond).
  • Node “Ternary (one line only)” (square).
  • Node “if / elif / else” (square).
  • Node “match / case” (square).
  • Node “dict of callables” (square).
  • Node “comprehension” (square).
  • Node “generator” (square).
  • Node “for loop” (square).
  • Node “while loop” (square).
  • “What are you doing?” leads to “Branching or iterating?”. Line: normal; ends: arrow point.
  • “Branching or iterating?” leads to “What kind of decision?”: branching. Line: normal; ends: arrow point.
  • “Branching or iterating?” leads to “Producing a new collection?”: iterating. Line: normal; ends: arrow point.
  • “What kind of decision?” leads to “Ternary (one line only)”: short value selection. Line: normal; ends: arrow point.
  • “What kind of decision?” leads to “if / elif / else”: arbitrary conditions. Line: normal; ends: arrow point.
  • “What kind of decision?” leads to “match / case”: structural patterns. Line: normal; ends: arrow point.
  • “What kind of decision?” leads to “dict of callables”: dispatch to handlers. Line: normal; ends: arrow point.
  • “Producing a new collection?” leads to “comprehension”: yes, all in memory. Line: normal; ends: arrow point.
  • “Producing a new collection?” leads to “generator”: yes, but lazy / streaming. Line: normal; ends: arrow point.
  • “Producing a new collection?” leads to “for loop”: no — side effects only. Line: normal; ends: arrow point.
  • “Producing a new collection?” leads to “while loop”: unknown # of steps. Line: normal; ends: arrow point.

On the iteration side, the key distinction is whether you need a materialised collection, a lazy stream, or side effects. The three versions of the squares calculation below let you compare those choices without changing the filtering task.

Comprehension vs for-loop — the same problem, three ways

All three versions select even numbers and square them; only the generator defers computing and storing results.

squares_loop = []
for n in range(10):
    if n % 2 == 0:
        squares_loop.append(n * n)
squares_list = [n * n for n in range(10) if n % 2 == 0]
squares_gen = (n * n for n in range(10) if n % 2 == 0)
assert squares_loop == squares_list == [0, 4, 16, 36, 64]
assert list(squares_gen) == squares_list
assert list(squares_gen) == []

The comprehension reads as WHAT, FROM WHAT, then WHERE; evaluation actually draws an item, checks the filter, and then evaluates the result expression. CPython can avoid explicit append-method overhead, but the expression still executes Python operations. A generator expression evaluates its leftmost iterable expression immediately; element transformations and filters are lazy. These forms are not interchangeable when you require repeated traversal or observable side effects.

match/case — not a switch, a destructurer

from dataclasses import dataclass
 
@dataclass
class Point:
    x: int
    y: int
 
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"user {name}; adult guard not satisfied"
        case [x, y, *rest]:                                 # sequence shape (not just lists)
            return f"sequence starting {x},{y} + {len(rest)} more"
        case Point(x=0, y=0):                               # class pattern
            return "origin"
        case _:                                             # fallthrough
            return "unknown"

When to use match/case (and when not)

Use match for structures such as mappings, sequences and class instances. Mapping patterns allow extra keys; sequence patterns also match tuples but not str/bytes. A guard can fail and allow a later case: missing age does not establish that a user is a minor. This demo assumes numeric ages when present; a string age can raise TypeError. if/elif, literal cases and dict dispatch are all legitimate when they express the contract clearly.

The itertools cheat sheet

Six itertools functions that replace hand-rolled loops

Name: chain(*iterables)

Desc: Flatten several iterables into one stream. chain([1,2], [3,4]) → 1,2,3,4.

Name: zip_longest(a, b, fillvalue=…)

Desc: Like zip but doesn't stop at the shorter one. Uses fillvalue for the gap.

Name: islice(it, start, stop, step)

Desc: Lazy forward slicing of an iterable, including generators. Negative indices and nonpositive steps are unsupported; consuming islice advances the underlying iterator.

Name: groupby(iterable, key=…)

Desc: Cluster CONSECUTIVE items with the same key. sort first if you want global groups.

Name: product(a, b, ...)

Desc: Cartesian product. product([1, 2], ["a", "b"]) yields (1, "a"), (1, "b"), (2, "a"), (2, "b"). It first pools its input iterables, so it is not suitable for an infinite input.

Name: accumulate(iterable, func=operator.add)

Desc: Running totals (or running max/min/whatever). accumulate([1,2,3,4]) → 1,3,6,10.

The mental model to hold

while repeats while a condition is true; each path back to the test needs a progress/termination argument. The assignment expression below both reads the next chunk and tests whether it is empty. enumerate supplies indices without separately advancing a counter; zip pairs inputs and checks strict length only while consumed.

from io import StringIO
from itertools import zip_longest, accumulate
 
stream = StringIO("abcdefg")
chunks = []
while (chunk := stream.read(3)):
    chunks.append(chunk)
assert chunks == ["abc", "def", "g"]
assert list(enumerate(["a", "b"], start=1)) == [(1, "a"), (2, "b")]
assert list(zip_longest([1, 2], ["a"], fillvalue=None)) == [(1, "a"), (2, None)]
assert list(accumulate([1, 2, 3])) == [1, 3, 6]
strict_pairs = zip([1, 2], ["a"], strict=True)
assert next(strict_pairs) == (1, "a")
try:
    next(strict_pairs)
except ValueError:
    pass
else:
    raise AssertionError("strict zip must reject unequal lengths on consumption")

Comprehensions are for transforming, loops are for effects

If the point of your loop is to produce a new collection, use a comprehension. If the point is to do things (log, save, network call, print), use a plain for. Mixing the two — a comprehension whose only purpose is a side effect — is code-smell #1 in Python reviews.

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

Truth: Python's for is built on the iterator protocol, not on indices. It calls iter(obj) once, then next() repeatedly until StopIteration. An iterator defines how it responds to changes; there is no universal live-cursor behavior. Removing list items can skip elements, changing dict size can raise RuntimeError, and deliberate replacement of existing list slots may be safe.

Why: 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: consecutive matches can be skipped as the iterator advances past shifted elements. The bug looks like "my filter only removed half the items", which is maddening precisely because half of it worked.

Test: the removal loop is a wrong answer; the second example is correct single-use iterator behavior.

xs = [1, 2, 2, 2, 3]
for x in xs:
    if x == 2:
        xs.remove(x)
assert xs == [1, 2, 3]   # a 2 survives: this is the bug
 
it = iter([1, 2, 3])
print(next(it), next(it))  # 1 2
assert list(it) == [3]
assert list(it) == []
xs = [x for x in xs if x != 2]
assert xs == [1, 3]

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.

Premise: The canonical use of a loop with break is a search: scan until you find something, then stop early.

Because: continuing after a hit is wasted work, and break is how you express "found it"

Premise: Every search therefore has two distinct exits — found (broke out) and exhausted (ran off the end) — and they usually need different follow-up code.

Because: "not found" is a real outcome that needs handling, not an error

Premise: Without language support you must encode which exit happened in a flag: set found = True before the break, then test it after the loop.

Because: after the loop ends, the loop body's control flow is no longer visible

Premise: 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.

Because: an unnecessary found flag introduces extra state to maintain; an explicit flag can still be appropriate for a more complex contract

Premise: So the language exposes the bit directly as a clause that runs on the exhausted exit only.

A loop can also leave by return or exception. The else suite runs on normal exhaustion or a false while condition, not after break and not after abrupt function exit.

Conclusion: In a search that uses break only for a match, loop-else represents “not found.” More generally it represents normal loop completion, including an empty iterable or an initially false while condition. A try-else runs only when the try suite finishes normally, not after a return, break, continue or exception. These are useful related constructs, not identical grammar rules.

def find_even(values):
    for value in values:
        if value % 2 == 0:
            break
    else:
        return None
    return value
 
def countdown(n):
    while n > 0:
        n -= 1
    else:
        return "done"
 
assert find_even([1, 3, 4, 6]) == 4
assert find_even([1, 3]) is None
assert find_even([]) is None
assert countdown(2) == countdown(0) == "done"

Name: A loop is a cursor, not a counter

Picture: 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.

  • Avoid structural changes during iteration unless the iterator contract permits them. Building a new list or iterating a copy avoids the removal bug; assigning to existing list indices without changing its length can be intentional.

  • Prefer enumerate, zip, and comprehensions over index arithmetic — they reduce manual index bookkeeping but do not eliminate all boundary or alignment mistakes.

  • break and continue affect the innermost enclosing loop; a loop-else suite belongs to the loop with which it is syntactically paired. Deep nesting that needs to escape several levels is a signal to extract a function and use return.

Trigger: 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.

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

Name: Explicit for loop

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

Cost: more lines, mutable accumulator state to get wrong, and the intent (map? filter? reduce?) is not visible until you read the body

Pick: the body needs more than one expression, or you need per-item error handling, or the loop has side effects like writing rows

Name: List comprehension

Gain: intent is declarable at a glance, no accumulator to mismanage, and the interpreter avoids repeated attribute lookup and method-call overhead — sometimes faster than an equivalent append loop in CPython; no workload-independent speed guarantee

Cost: materialises the entire result in memory, cannot early-exit cleanly, and becomes unreadable past one condition and one nesting level

Pick: the transform is a single expression, the result fits comfortably in memory, and you want the result as a concrete reusable list

Name: Generator expression / yield

Gain: avoids eagerly storing all outputs and can compose into pipelines. A downstream next() or any() can stop early, but a filter may inspect many inputs before its first result. Retained inputs, transform state, individual items and consumer buffering determine memory use

Cost: single-use and not indexable or len()-able; errors in lazy transformations surface during consumption, while the leftmost iterable expression of a generator expression is evaluated immediately

Pick: 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

Verdict: 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 may obscure evaluation order; readability and performance must be assessed separately.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. Streaming helps only if the input source, transform and consumer also avoid unbounded buffering.


(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:
    # This is intentionally incomplete, not an alternative grader.
    # Exact-key dispatch does not implement numeric ranges; keep grade_if.
    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 without a result list; live strings/integers still occupy memory
total_chars = sum(len(line) for line in read_lines_lazy(1_000_000))
print(f"processed 1M lines without retaining them; 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

Name: Part 1 · pick your branch

Desc: if/elif for ranges. match for structural patterns. ternary for one-line value selection. dict dispatch for callable handlers. Match intent to shape.

Name: Part 2 · comprehension power

Desc: Reads left-to-right: [WHAT for X in FROM if WHERE]. Dict and set comprehensions use the same shape with {} braces.

Name: Part 3 · idioms

Desc: enumerate replaces for i in range(len(...)). zip pairs sequences. strict=True (3.10+) catches length mismatches.

Name: Part 4 · generator streaming

Desc: Calling a function containing yield returns a generator iterator. Its body runs on advancement. This example avoids accumulating lines, but memory also includes the current line, source and reducer state. It is not a pandas/Polars benchmark.

Name: Part 5 · itertools

Desc: chain, product, groupby and islice name useful iteration contracts. product pools finite inputs; groupby group iterators share their source; C implementation alone does not establish end-to-end speed.

Name: Part 6 · readability limits

Desc: One filter, one source: comprehension is best. Two nested for-clauses OR two if-clauses: promote to a real loop. Save reviewers pain.

What to observe

Part 1’s match binds values while checking mapping structure; an if-based implementation can do the same job with explicit checks. Part 4 processes one million lines without retaining all of them. Elapsed time and peak bytes depend on the interpreter and machine; neither a one-second limit nor zero memory is promised. Part 5's groupby requires sorted input for global grouping — it only groups adjacent equal keys. Part 6 produces the same output in both versions; the readability ranking is a discussion, not a measured human timing claim.

Goal: Refactor a bad comprehension into a readable loop, then a good comprehension

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.

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.

Worked solutions. Ties retain input order because sorted is stable. The third version recomputes count for retained users; concise does not imply less work.

def error_counts_loop(records):
    result = []
    for record in records:
        count = 0
        for status in record["logins"]:
            if status == 500:
                count += 1
        if count:
            result.append((record["user"], count))
    return sorted(result, key=lambda pair: pair[1], reverse=True)
 
def error_counts_mixed(records):
    result = []
    for record in records:
        count = sum(1 for status in record["logins"] if status == 500)
        if count:
            result.append((record["user"], count))
    return sorted(result, key=lambda pair: pair[1], reverse=True)
 
def error_counts_comprehension(records):
    return sorted(
        [(r["user"], r["logins"].count(500))
         for r in records if r["logins"].count(500)],
        key=lambda pair: pair[1], reverse=True,
    )
 
expected = [("ada", 1), ("linus", 1)]
assert error_counts_loop(records) == expected
assert error_counts_mixed(records) == expected
assert error_counts_comprehension(records) == expected

I prefer the mixed version here: one count per user and a visible filter. The plain loop makes counting explicit for debugging. The comprehension is valid for these list-valued logins, but repeats the scan. For r input records, L total login entries and k retained users, all three take O(r + L + k log k) elementary operations: empty login lists still require visiting their records.


(d) Production reality · 15 min

Three bounded production scenarios

These are illustrative failures, not attributed incident or latency reports.

Count and preview a single-use source in one pass. Consuming a generator to count it leaves nothing for a later preview. tee is not a constant-memory escape hatch: it buffers items until all clones catch up. Accumulate a fixed-size preview while counting instead.

from itertools import islice
 
rows = (int(line) for line in ["1", "2", "3"])
count = sum(1 for _ in rows)
assert count == 3 and list(islice(rows, 5)) == []
 
def count_and_preview(rows, limit=5):
    if limit < 0:
        raise ValueError("limit must be nonnegative")
    count = 0
    preview = []
    for row in rows:
        count += 1
        if len(preview) < limit:
            preview.append(row)
    return count, preview
 
assert count_and_preview(iter(range(8))) == (8, [0, 1, 2, 3, 4])

For a fixed limit, preview storage is bounded by that many references, while the count integer grows with the number of rows. A real file should be owned by a with open(...) block covering consumption; no file is opened in this fixture.

Repeated membership checks depend on the data structure. With m queries and n list entries, scanning a list costs O(mn) worst case. Building a set once costs expected O(n) space/time and then expected O(m) lookup work under ordinary hashes and bounded equality cost. Collisions, unhashable elements, tiny inputs and order requirements can change the choice; no specific latency reduction follows from this model.

valid_ids = [10, 20, 30]
queries = [20, 99, 10, 20]
valid_set = set(valid_ids)
assert [q for q in queries if q in valid_ids] == [q for q in queries if q in valid_set]

Parse once, then filter. A walrus expression can be readable, but a named helper separates transformation and missing-result handling without parsing twice.

def parse_optional(line):
    return int(line) if line.strip() else None
 
def parsed_lines(lines):
    for line in lines:
        parsed = parse_optional(line)
        if parsed is not None:
            yield parsed
 
assert list(parsed_lines(["1", "", "0", "2"])) == [1, 0, 2]

Testing is not None retains the legitimate value zero. This mechanism, rather than an alleged review rejection, is the reason for the example.

Where this shows up in the rest of the plan

Root: Control flow shapes bleed into every Python session ahead

Label: S007 · Functions

Desc: *args, **kwargs, and the return-early pattern all lean on the branch shapes here.

Label: S008 · Data structures

Desc: Repeated membership checks connect iteration work to list-versus-set contracts.

Label: S011 · Errors & debugging

Desc: try/except is another branch. Use the API’s error contract; Python also deliberately uses exceptions such as StopIteration as protocol signals.

Label: Later topic · Dataframe transforms

Desc: Array operations can express transforms over collections; their execution and allocation contracts differ from Python comprehensions.

Label: Later topic · Asynchronous iteration

Desc: async for uses asynchronous iteration; it does not by itself run iterations concurrently.

Label: Later topic · Data pipelines

Desc: Streaming vs materialised is a system-design decision; today was the local version.


(e) Recall + stretch · 10 min

Self-check (answer follows each question).

Recall — questions with model answers

Q: When would you use match/case over if/elif?

A: Use match when structural patterns and guards express the input contract clearly. if/elif handles arbitrary conditions; literal cases and dict dispatch can both be appropriate. Branch counts such as five are style heuristics, not correctness rules.

Q: Rewrite [x for x in items if pred(x)] as an equivalent generator expression. What's the trade-off?

A: (x for x in items if pred(x)). Trade-off: the generator avoids storing all output references but retains its source and evaluation state, and is single-use. A list stores O(n) output references and supports len(), indexing and repeated traversal. Actual bytes also depend on item sizes.

Q: What's wrong with for line in f: print(line) if f is a large file?

A: Nothing! Iterating a file object streams line-by-line, retaining buffering and the current line rather than the entire file; one enormous line can still consume substantial memory. It's f.read() or f.readlines() that loads the whole file. Iteration is the right default.

Q: You have a list of dicts and want a dict keyed by id. Write the one-liner.

A: by_id = {d["id"]: d for d in items}. Each record must contain a hashable id; later records replace earlier ones with an equal id. The mapping stores the original dict references, not copies.

Q: What does zip(a, b, strict=True) do differently from zip(a, b)?

A: strict=True (Python 3.10+) raises ValueError when consumption reaches a length mismatch; merely constructing the zip object does not check all lengths. Default zip silently stops at the shorter one — a common source of dropped-data bugs.

Q: When should you use dict.get(key, default) instead of if key in d: …?

A: Use d.get(k, default) when a missing key should yield a fallback value; a present None remains None. The default expression is evaluated before get is called, even if the key exists. An explicit if is useful for conditional work or a distinct missing-key branch.

Q: What's itertools.groupby NOT do that most people expect it to?

A: Global grouping. It only clusters CONSECUTIVE equal keys. To group globally, sorted(items, key=…) first, THEN groupby with the same key.

Q: What's wrong with for item in list: if condition: list.remove(item)?

A: Removing items can skip later matches as list positions shift beneath the iterator. This is not a ban on deliberate replacement of existing slots. Fix: iterate a copy (for item in list[:]) or build a new list with a comprehension (list = [i for i in list if not condition(i)]).

Q: Explain the walrus operator (:=) and one legitimate use case.

A: Assignment expression — assigns AND returns a value inside a larger expression. Legit use: while (chunk := f.read(4096)): process(chunk). Reads and tests in one line without an infinite-loop-with-break. Overuse it and reviewers will (rightly) push back.

Q: Stretch — you have a stream of log lines. Compute the top-3 most frequent status codes without loading the whole file into memory.

Stretch: true

A: Count while iterating, using a parser with an explicit log-format contract. A blind split()[8] assumes the quoted request always contains exactly three whitespace-separated words; validate that structure instead. This minimal parser accepts the stated combined-log prefix, not every configurable server format.

from collections import Counter
import re
 
LOG_PREFIX = re.compile(
    r'^\S+ \S+ \S+ \[[^\]]+\] "[^"\r\n]*" ([0-9]{3}) (?:[0-9]+|-)(?: |$)'
)
 
def status_of(line: str) -> str:
    match = LOG_PREFIX.match(line)
    if match is None:
        raise ValueError("unsupported or malformed combined-log prefix")
    return match.group(1)
 
def top_statuses(lines):
    counts = Counter(status_of(line) for line in lines)
    return counts.most_common(3)
 
sample = '127.0.0.1 - - [09/Oct/2026:00:00:00 +0000] "GET / HTTP/1.1" 200 12 "-" "agent"'
assert top_statuses(iter([sample, sample])) == [("200", 2)]

For a real file, call top_statuses inside with open("access.log") as f:. Memory is O(k) counters for k distinct parsed codes, plus the current line and integer sizes—not zero memory and not a general guarantee for arbitrary keys. Equal-frequency results follow first-encounter order. A malformed line raises under this chosen contract; deciding to skip it requires an explicit error policy.

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)

Model answers: (1) Use a comprehension for a readable value transform whose materialized result is wanted; use a loop for statements, early exit or effects. (2) A list retains outputs for reuse; a generator computes on advancement and retains source/state without automatically bounding their size. (3) Structural destructuring and ordered guarded patterns are strong reasons for match; branch count alone is not.


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.

Corrective source notes

Versioned Python 3.12 contracts were checked on 2026-10-09; later-version history is labeled separately. Local examples are bounded teaching programs, not service or performance certification. The review packet retains exact source quotations, executed code, negative controls and coverage limits.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.