Control Flow
if/elif/else, modern match/case, loops, the walrus operator, and the for/else clause that almost nobody knows about.
What you'll learn
- if/elif/else and the truthiness rules that shorten your branches
- match/case patterns for parsing shaped data like API payloads
- Loop tools — break, continue, for/else, and the walrus operator
- When while beats for, and when the walrus genuinely reads better
Before you start
Control flow is the moment a program stops being a list of statements and starts making decisions. Most of Python’s branching will look familiar from any language. But a few pieces are distinctly Pythonic — truthiness that lets a list stand in for a condition, a match statement that takes data apart as it branches, and small touches like the walrus operator and the loop else that tidy up code other languages have to write the long way. Those are the parts worth dwelling on.
if / elif / else
The shape is conventional; one detail is not. Watch how the range checks are written:
status = 503
if 200 <= status < 300:
result = "success"
elif 300 <= status < 400:
result = "redirect"
elif 400 <= status < 500:
result = "client error"
elif 500 <= status < 600:
result = "server error"
else:
result = "unknown"
print(result)
server error
That 200 <= status < 300 is a chained comparison, and Python evaluates it exactly as the mathematics reads — (200 <= status) and (status < 300). Most languages forbid it; Python lets you write the interval the way you would on paper.
Truthiness — write less code
Every Python value carries a truth value, so you can drop a list, a string, or a dictionary straight into an if and it does the sensible thing. The useful part is knowing exactly which values count as false, because the set is small and fixed:
falsy_values = [False, None, 0, 0.0, "", [], {}, set(), ()]
for v in falsy_values:
print(f"{v!r} {'truthy' if v else 'falsy'}")
False falsy
None falsy
0 falsy
0.0 falsy
'' falsy
[] falsy
{} falsy
set() falsy
() falsy
Every one of those is falsy, and crucially, everything else is truthy. That is what lets you write if items: instead of if len(items) > 0:, and if name: instead of if name is not None and name != "":.
Those nine on the left are the entire falsy set. Every other value is truthy — which is why if items: works.
match / case — pattern matching
match arrived in Python 3.10, and calling it a switch statement sells it short. It does not just compare a value against constants — it destructures data as it matches, which makes it a natural fit for shaped payloads like API events:
def handle(event):
match event:
case {"type": "user.created", "user": {"id": user_id, "email": email}}:
return f"new user {user_id} <{email}>"
case {"type": "user.deleted", "user": {"id": user_id}}:
return f"deleted user {user_id}"
case {"type": "payment", "amount": amount} if amount > 1000:
return f"large payment alert: {amount}"
case {"type": "payment", "amount": amount}:
return f"payment: {amount}"
case {"type": event_type}:
return f"unknown event type: {event_type}"
case _:
return "malformed event"
events = [
{"type": "user.created", "user": {"id": 42, "email": "a@x.com"}},
{"type": "payment", "amount": 5000},
{"type": "payment", "amount": 12},
{"type": "newsletter.subscribed"},
{},
]
for e in events:
print(handle(e))
new user 42 <a@x.com>
large payment alert: 5000
payment: 12
unknown event type: newsletter.subscribed
malformed event
Three mechanisms are doing the work. The dictionary patterns destructure — user_id and email are pulled out of nested dictionaries and bound to local names in one move. The if amount > 1000 is a guard: that case matches only when the shape fits and the guard is true, which is why the 5000 payment took the alert branch and the 12 payment fell through to the next. And case _: is the catch-all that handled the empty event. For routing message types, parsing JSON, or any shape-driven branching, this is dramatically clearer than a stack of nested if/elif.
for loops
Python’s for always iterates over a collection — there is no C-style for (i = 0; i < n; i++). When you need the index too, that is what enumerate is for, and when you need to walk two sequences together, that is zip:
tasks = ["fetch", "transform", "validate", "load"]
for t in tasks:
print(t)
print()
# With an index — enumerate, starting the count at 1.
for i, t in enumerate(tasks, start=1):
print(f"{i}. {t}")
print()
# Two sequences in step — zip.
durations_ms = [120, 450, 80, 200]
for task, ms in zip(tasks, durations_ms):
print(f"{task:<10} {ms}ms")
fetch
transform
validate
load
1. fetch
2. transform
3. validate
4. load
fetch 120ms
transform 450ms
validate 80ms
load 200ms
enumerate and zip are among the most-used helpers in the language. The moment you catch yourself writing for i in range(len(things)), stop — enumerate(things) is what you actually want.
break, continue, and the loop’s else
break leaves the loop entirely; continue skips to the next iteration. Less famous, and genuinely useful, is the loop else — a block attached to the loop that runs only if the loop finished without hitting a break:
rows = [
{"id": 1, "status": "ok"},
{"id": 2, "status": "ok"},
{"id": 3, "status": "error"},
{"id": 4, "status": "ok"},
]
for r in rows:
if r["status"] == "error":
print(f"found error at id {r['id']}")
break
else:
print("no errors") # runs only if we never broke out
# Run again over all-clean data.
clean = [{"id": i, "status": "ok"} for i in range(1, 4)]
for r in clean:
if r["status"] == "error":
print("found error")
break
else:
print("no errors")
found error at id 3
no errors
Read the two runs together and the else clicks. The first loop found an error and broke, so its else was skipped. The second loop reached the end without breaking, so its else fired with “no errors”. That is precisely the “did the search find nothing?” case, expressed without the usual found = False sentinel variable. It is rare in code only because few people learn it.
while loops
When you do not know in advance how many iterations you need — retries, polling, “until the stream runs dry” — while is the right tool:
# Retry a flaky step until it succeeds. Here the third try is the one that works.
responses = ["FAIL", "FAIL", "OK", "OK"] # what the service returns each attempt
attempt = 0
max_attempts = 5
while attempt < max_attempts:
result = responses[attempt]
attempt += 1
print(f"attempt {attempt}: {result}")
if result == "OK":
break
else:
print("gave up after exhausting all attempts")
attempt 1: FAIL
attempt 2: FAIL
attempt 3: OK
The success on the third attempt broke the loop, so the else stayed quiet — exactly as it would for a for. Had every attempt failed and the condition simply gone false, the else would have run instead, reporting that we gave up. A while’s else means the same thing: “the condition became false, and nobody broke out.”
The walrus operator
Since Python 3.8, := — nicknamed the walrus — lets you assign a value inside an expression, binding and using it in a single step:
items = ["a", "b", "c", "d", "e"]
# Compute n once, then branch on it — in one line.
if (n := len(items)) > 3:
print(f"got {n} items — more than expected")
# The classic use: pull items until a sentinel (here, "") ends the stream.
data_stream = iter(["chunk1", "chunk2", "chunk3", ""])
while chunk := next(data_stream, ""):
print(f"processing {chunk}")
got 5 items — more than expected
processing chunk1
processing chunk2
processing chunk3
The walrus earns its keep in two situations: computing a value once and immediately branching on it — if (size := compute_size()) > N: — and reading until something is exhausted, as the chunk loop does. Note the parentheses around n := len(items); without them Python tries to parse the assignment as part of the comparison and raises a syntax error. Used for those two patterns it is the cleanest option; sprinkled everywhere it makes code dense and hard to read, so use it with restraint.
In one breath
- Chained comparisons like
200 <= x < 300read as the interval and meanand. - The falsy set is fixed and small —
False None 0 0.0 '' [] {} () set()— everything else is truthy. match/casedestructures shaped data and supports guards (if ...) and acase _:catch-all.for/whileboth take anelsethat runs only when the loop finished withoutbreak.- Use
enumerate/zipoverrange(len(...)); reserve the walrus:=for “compute-and-branch” and “read-until-empty”.
Practice
Quick check
What’s next
You can branch, loop, and destructure. Now it is time to package logic for reuse — next come functions, with a close look at Python’s argument patterns and the famous mutable-default trap.
Practice this in an interview
All questionsThe else clause runs only when the try block exits without raising an exception — it lets you separate success-path code from the guarded block. The finally clause runs in every case: after normal exit, after an exception (caught or uncaught), and after a return or break inside try or except. The only situations where finally is skipped are a hard interpreter crash (SIGKILL, os._exit, power loss).
lambda, map, and filter are concise for simple one-liners passed to higher-order functions, but list/generator comprehensions are usually more readable. reduce belongs in functools and is best reserved for cases where the fold operation is non-trivial; an explicit loop is often clearer.
Comprehensions are syntactic sugar for building a new collection by iterating over an iterable and optionally filtering elements. They are faster than equivalent for-loops because the iteration runs at the C level inside the interpreter. Avoid them when the expression is too complex to read at a glance — a plain loop with descriptive variable names is preferable.
A context manager is any object that implements __enter__ and __exit__. The with statement calls __enter__ on entry and __exit__ on exit — guaranteed, even if an exception is raised. This makes with the idiomatic way to manage resources like files, locks, and database transactions without leaking them.