datarekha

Control Flow

if/elif/else, modern match/case, loops, the walrus operator, and the for/else clause that almost nobody knows about.

10 min read Beginner Python Lesson 10 of 41

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 != "":.

if value:falsytruthySKIPS THE BLOCK — the complete setFalse None 0 0.0” [] {} () set()RUNS THE BLOCK — everything else1 -1 ‘x’ 3.14[0] {‘a’: 1} anything

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 destructureuser_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 < 300 read as the interval and mean and.
  • The falsy set is fixed and small — False None 0 0.0 '' [] {} () set() — everything else is truthy.
  • match/case destructures shaped data and supports guards (if ...) and a case _: catch-all.
  • for/while both take an else that runs only when the loop finished without break.
  • Use enumerate/zip over range(len(...)); reserve the walrus := for “compute-and-branch” and “read-until-empty”.

Practice

Quick check

0/3
Q1What does `if []:` evaluate to?
Q2Which match case correctly extracts the id from `{'type': 'user', 'id': 42}`?
Q3What does the `else` block on a for loop do?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What do the else and finally clauses of a try block do, and when does finally NOT run?

The 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).

When should you use lambda, map, filter, and reduce — and when should you avoid them?

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.

How do list, dict, and set comprehensions work in Python, and when should you avoid them?

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.

What is a context manager and how does the with statement work?

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.

Related lessons

Explore further

Skip to content