04 — Control Flow
Production State Machine with match/case (3.10+)
# ── Real-world: async task state machine using structural pattern matching ──
# Models a job scheduler's lifecycle — each state transition is a pattern match
# against the current state + event tuple, with guards for conditional transitions.
from dataclasses import dataclass
from enum import Enum, auto
class State(Enum):
PENDING = auto()
RUNNING = auto()
PAUSED = auto()
COMPLETED = auto()
FAILED = auto()
@dataclass
class Task:
id: str
state: State = State.PENDING
retries: int = 0
error: str | None = None
def transition(task: Task, event: str, payload: dict | None = None) -> Task:
"""State machine: (current_state, event) → new_state, with guard clauses."""
payload = payload or {}
match (task.state, event):
case (State.PENDING, "start"):
task.state = State.RUNNING
case (State.RUNNING, "pause"):
task.state = State.PAUSED
case (State.PAUSED, "resume"):
task.state = State.RUNNING
case (State.RUNNING, "complete") if payload.get("success", True):
task.state = State.COMPLETED
case (State.RUNNING, "complete"):
task.error = payload.get("error", "unknown")
task.state = State.FAILED
case (State.FAILED, "retry") if task.retries < 3: # guard: max 3 retries
task.retries += 1
task.error = None
task.state = State.PENDING
case (State.FAILED, "retry"):
raise RuntimeError(f"Task {task.id} exhausted retries ({task.retries})")
case (State.COMPLETED, _):
pass # terminal state — any event is a no-op
case (state, event):
raise ValueError(f"Invalid transition: {state.name} + {event}")
return task
# Drive the state machine through a realistic lifecycle
task = Task("job-42")
transition(task, "start")
transition(task, "pause")
transition(task, "resume")
transition(task, "complete", {"success": False, "error": "OOM"})
print(task) # Task(id='job-42', state=<State.FAILED: 5>, retries=0, error='OOM')
transition(task, "retry")
print(task.state) # State.PENDING — back in queue
while Loops
def collatz_steps(n):
steps = 0
while n != 1:
n = n // 2 if n % 2 == 0 else 3 * n + 1
steps += 1
return steps
print(collatz_steps(27)) # 111
while True with break — the idiomatic "loop until condition found inside"
import random
def guess_number(target):
attempts = 0
while True:
attempts += 1
guess = random.randint(1, 100)
if guess == target:
return attempts
if attempts > 10_000: # safety valve against infinite loops
raise RuntimeError("too many attempts")
for Loops — Iterating, Not Counting
Python's for iterates over any iterable (chapter 19 covers the protocol in depth) — there is no C-style for (i = 0; i < n; i++).
for fruit in ["apple", "banana", "cherry"]:
print(fruit)
# Need an index too? Use enumerate — don't manually track a counter
for i, fruit in enumerate(["apple", "banana", "cherry"], start=1):
print(f"{i}. {fruit}")
# Need a numeric range? range() is lazy — doesn't build a list
for i in range(5): # 0, 1, 2, 3, 4
print(i)
for i in range(2, 10, 2): # 2, 4, 6, 8 — start, stop, step
print(i)
for i in range(10, 0, -1): # counts down: 10, 9, ..., 1
print(i)
Iterating multiple sequences together with zip
names = ["Ada", "Grace", "Alan"]
scores = [98, 95, 87]
for name, score in zip(names, scores):
print(f"{name}: {score}")
# zip stops at the SHORTEST iterable — silent truncation, not an error
extra = ["Ada", "Grace", "Alan", "Linus"]
for name, score in zip(extra, scores):
print(name) # Linus is silently dropped — no error, no warning
Use itertools.zip_longest(names, scores, fillvalue=None) from the standard library when mismatched lengths should be padded rather than silently truncated.
break, continue, and the Rarely-Known else on Loops
break exits a loop immediately. continue skips to the next iteration. Both work identically to C/JS. What's unique to Python: for and while loops can have an else clause, which runs only if the loop completed without hitting a break.
def find_first_prime_factor(n):
for candidate in range(2, int(n ** 0.5) + 1):
if n % candidate == 0:
print(f"Found factor: {candidate}")
break
else:
# Runs ONLY if the loop never broke — i.e., n is prime
# (or n < 4, where the range is empty and the loop trivially "completes")
print(f"{n} is prime")
find_first_prime_factor(15) # Found factor: 3
find_first_prime_factor(17) # 17 is prime
The mental model: else on a loop means "no-break." It is most useful for search loops where you need to distinguish "found it, handled inside the loop" from "searched everything, found nothing" — without a separate found = False flag variable.
# WITHOUT for-else — needs an extra flag variable
def contains_duplicate_verbose(items):
seen = set()
found = False
for item in items:
if item in seen:
found = True
break
seen.add(item)
if not found:
print("No duplicates")
else:
print(f"Duplicate: {item}")
# WITH for-else — no flag needed, and scope is naturally clearer
def contains_duplicate(items):
seen = set()
for item in items:
if item in seen:
print(f"Duplicate: {item}")
break
seen.add(item)
else:
print("No duplicates")
while ... else follows the same rule and is far less commonly used in practice, but behaves identically — the else runs unless a break fired.
Structural Pattern Matching — match / case (3.10+)
PEP 634 added match/case. It looks like a switch statement but is dramatically more powerful — it does structural destructuring, not just value equality.
Basic literal matching
def http_status_message(code):
match code:
case 200:
return "OK"
case 404:
return "Not Found"
case 500 | 502 | 503: # OR-pattern
return "Server Error"
case _ if 400 <= code < 500: # guard clause
return "Client Error"
case _: # wildcard — like `default`
return "Unknown"
print(http_status_message(404)) # Not Found
print(http_status_message(503)) # Server Error
print(http_status_message(422)) # Client Error
Structural destructuring — matching shape, not just value
def handle_event(event):
match event:
case {"type": "click", "x": x, "y": y}:
return f"Click at ({x}, {y})"
case {"type": "keypress", "key": str(key)}:
return f"Key pressed: {key}"
case {"type": "resize", "width": w, "height": h} if w <= 0 or h <= 0:
return "Invalid resize dimensions"
case {"type": "resize", **rest}:
return f"Resized: {rest}"
case _:
return "Unknown event"
print(handle_event({"type": "click", "x": 10, "y": 20}))
print(handle_event({"type": "resize", "width": 800, "height": 600}))
print(handle_event({"type": "resize", "width": -1, "height": 600}))
Matching classes structurally
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def describe(point):
match point:
case Point(x=0, y=0):
return "Origin"
case Point(x=0, y=y):
return f"On the Y-axis at {y}"
case Point(x=x, y=0):
return f"On the X-axis at {x}"
case Point(x=x, y=y) if x == y:
return "On the diagonal"
case Point():
return "Somewhere else"
case _:
return "Not a point"
print(describe(Point(0, 0))) # Origin
print(describe(Point(3, 3))) # On the diagonal
print(describe(Point(0, 5))) # On the Y-axis at 5
Sequence patterns with unpacking and "the rest"
def process_command(parts):
match parts:
case []:
return "empty command"
case [cmd]:
return f"run {cmd} with no args"
case [cmd, *args] if cmd == "echo":
return " ".join(args)
case ["move", x, y]:
return f"move to {x}, {y}"
case [cmd, *_]:
return f"unrecognized command: {cmd}"
print(process_command(["echo", "hello", "world"])) # hello world
print(process_command(["move", 3, 4])) # move to 3, 4
print(process_command([])) # empty command
💡 Tips & Tricks
for...elsefor search loops eliminates flag variables — anywhere you'd writefound = Falsebefore a loop just to check it after, reach forfor...elseinstead; it reads as "did I search everything without finding it."case _:must be last, and a bare name incasealways matches (and binds!) — not compares —case x:(no literal, no structure) always matches and binds the value tox; to match against an existing variable's value, use a dotted name or guard:case value if value == existing_var:orcase SomeEnum.MEMBER:.itertools.zip_longestavoidszip's silent truncation — whenever mismatched-length inputs should be an explicit case (padding or erroring), don't reach for the builtinzip.sorted(..., key=...)beats writing manual comparison loops — most "loop to find the max/min/sorted order" code is better expressed withmax(items, key=...),min(items, key=...), orsorted(items, key=...)than a hand-rolledforloop.- Guard clauses (
ifinsidecase) let you avoid deeply nestedifinsidecasebodies — keep matching logic flat by pushing conditions into thecaseline itself.
⚠️ Edge Cases & Gotchas
- A bare name pattern in
matchalways matches and shadows — it never compares to an existing variable —case status:inside amatch status:block does NOT mean "matches if status equals status" (that's a no-op tautology anyway) — more subtly,case some_variable:wheresome_variablewas defined outside the match block still just binds a new local, it does not compare against the outer variable's value. Usecase value if value == some_variable:or wrap incase SomeClass.CONSTANT:(dotted/attribute patterns compare, bare names bind). zip()silently truncates to the shortest iterable — no error, no warning — a length mismatch between two lists you expected to be equal-length produces quietly wrong output rather than a crash, which makes it a debugging trap in data-pipeline code.range(start, stop)never includesstop, and negative steps requirestopto be reachable in the negative direction —range(5, 0)(no step) is an empty range, not an error and not a descending range — you must passrange(5, 0, -1)explicitly to count down.while/forelseis one of the most misread pieces of syntax in the language — many experienced developers coming from other languages assumeelseon a loop means "if the loop body never executed" (like an empty-collection check); it actually means "if the loop was not exited viabreak," which is a materially different condition especially for loops with zero iterations (theelsestill runs in that case, since nobreakoccurred).- Mutating a list while iterating over it with a
forloop skips elements —for x in lst: if cond: lst.remove(x)silently skips every other matching element, because removal shifts subsequent elements into the position the iterator has already passed. Iterate over a copy (for x in lst[:]:) or build a new list via a comprehension instead.
🧠 Spot the Bug
What does this print?
def find_negative(numbers):
for n in numbers:
if n < 0:
result = "found a negative"
break
else:
result = "all non-negative"
return result
print(find_negative([]))
print(find_negative([1, 2, 3]))
print(find_negative([1, -2, 3]))
Answer
Prints all non-negative, all non-negative, found a negative. The first case surprises many readers: an empty list never executes the loop body at all, so break never runs — and since the else clause's condition is precisely "the loop finished without break," it fires even though the loop body ran zero times. for...else's else is not an "if nothing matched after searching" clause in the intuitive sense — it is purely "no break occurred," and a loop over an empty (or already-satisfied-before-entry) iterable trivially satisfies that.
The lesson: for...else's else fires whenever a break statement did not execute, including when the loop body never ran — treat it as "no-break", not as "search exhausted with a non-trivial search."
Key Takeaways
- Python has no
switch/ternary in the C sense — useif/elif/else, thex if cond else yconditional expression, or (3.10+)match/casefor structural matching. foriterates over iterables, not indices — useenumerate()for index+value andzip()for parallel iteration (rememberingzipsilently truncates to the shortest input).for/whileloops support anelseclause that runs unless abreakoccurred — useful for search loops, but easy to misread as "loop found nothing."match/casedoes structural destructuring (dicts, sequences, classes via__match_args__), not just value comparison — a bare name in acasealways binds, it never compares against an outer variable.- Never mutate a list while iterating over it directly — iterate a copy or build a new collection instead.