Control Flow: if, for, while, and match

How Python decides what runs next — conditionals and the ternary, for-each iteration done idiomatically, while loops, break/continue and the surprising loop-else, structural pattern matching with match/case, and where truthiness and comprehensions fit in.

Control flow is the set of rules that decide which statements run and how many times. Every language has these constructs, but Python’s have a distinct grain: its for iterates over things rather than counting, its match matches structure rather than switching on a value, and loops can carry an else clause that most people never learn exists. Learn the grain and your code stops fighting the language. This guide walks the whole toolkit — if, the conditional expression, for, while, break/continue, match/case, truthiness, and comprehensions — with an eye on the idioms that separate Python written in Python from Python written in the accent of C or Java.


Conditionals: if / elif / else

The workhorse. An if statement runs a block when its condition is truthy, optionally chaining elif branches and a final else. There is no switch here — a chain of elif is the plain-conditional tool, and match (below) is the structural one.

def grade(score: int) -> str:
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "F"

Branches are tested top to bottom and the first truthy one wins, so order your conditions from most to least specific. Note there is no fall-through: exactly one branch of an if/elif/else chain executes.

The gotcha: Python has no block delimiters — indentation is the block. A misindented line silently joins (or leaves) a branch instead of raising an error. This is why mixing tabs and spaces is forbidden (TabError) and why a stray indent can change behavior without changing a single character you’d notice in review.


The conditional expression (Python’s ternary)

When you only need to choose a value, a full if statement is heavy. The conditional expression picks between two values inline:

status = "adult" if age >= 18 else "minor"

Read it left to right: the value before if is returned when the condition holds, otherwise the value after else. Because it’s an expression, it can go anywhere a value can — inside a function call, a list element, an f-string. The else is mandatory; an expression must always evaluate to something.

The gotcha: it’s tempting to nest these (a if p else b if q else c), but a chained conditional expression reads worse than the if/elif statement it replaces. Reach for the ternary only when both outcomes are simple values and the whole thing fits comfortably on one line.


for is for-each, not a counter

This is the single biggest adjustment for people arriving from C, Java, or JavaScript. Python’s for does not increment a counter and test a bound — it walks an iterable, handing you each element in turn:

for name in ["Ada", "Linus", "Grace"]:
    print(name)

There is no index variable, no i++, no length check. Anything iterable works the same way: lists, tuples, strings (character by character), dict keys, sets, generators, file objects (line by line). Once you internalize “for-each over an iterable,” most loops write themselves.

The anti-pattern to unlearn is for i in range(len(items)) followed by items[i]. It reintroduces a counter you don’t need and an index you have to keep in sync. Python gives you three tools that cover almost every case where you think you need the index.

Use enumerate when you genuinely need the index alongside the value:

for position, name in enumerate(names, start=1):
    print(f"{position}. {name}")

Use zip to walk two (or more) sequences in lockstep:

for name, score in zip(names, scores):
    print(f"{name}: {score}")

Use range only when you truly want numbers — a fixed count of repetitions, or an arithmetic sequence:

for _ in range(3):        # do something three times; value unused
    retry()

for n in range(0, 100, 10):  # 0, 10, 20, ... 90
    print(n)

The gotcha: zip stops at the shortest input and silently drops the tail of the longer one — a frequent source of quietly-lost data. If the inputs should be the same length, zip(a, b, strict=True) (Python 3.10+) raises ValueError on a mismatch instead of truncating. And range(stop) excludes stop itself: range(3) yields 0, 1, 2.


while: loop on a condition

Use while when you don’t know the iteration count up front — you loop until some condition changes. Reading input until end-of-stream, polling until a state settles, retrying until success:

def read_positive() -> int:
    value = -1
    while value < 0:
        raw = input("Enter a positive number: ")
        if raw.isdigit():
            value = int(raw)
    return value

If the loop is over a collection with a known length, prefer for — it’s clearer and can’t accidentally spin forever. Reserve while for genuinely condition-driven repetition.

The gotcha: a while loop whose condition never becomes false runs forever. The classic cause is forgetting to advance the state inside the body (never reassigning value above, say). Every while needs at least one path that moves the condition toward False, or an explicit break.


break and continue

Two statements steer a loop from the inside. break exits the nearest enclosing loop immediately; continue skips the rest of the current iteration and jumps to the next one.

def first_even(numbers):
    for n in numbers:
        if n % 2 != 0:
            continue      # skip odd numbers
        return n          # (a plain return also exits the loop)

def find_sentinel(lines):
    for line in lines:
        if line.strip() == "STOP":
            break         # stop scanning entirely
        process(line)

Both affect only the innermost loop. Python has no labeled break; to exit nested loops, factor the inner loop into a function and return, or use a flag.


The loop-else clause

Both for and while accept an else clause, and its meaning surprises nearly everyone: the else block runs only if the loop completed without hitting break. It’s really a “no-break” clause — read it as “if we searched the whole thing and never bailed out early.”

This is exactly the shape of a search that must distinguish “found it” from “exhausted everything”:

def find_divisor(n: int) -> int | None:
    for candidate in range(2, n):
        if n % candidate == 0:
            print(f"{n} is divisible by {candidate}")
            break
    else:
        # only reached if the loop never broke — i.e. no divisor found
        print(f"{n} is prime")
        return None
    return candidate

Without loop-else you’d carry a found = False flag and check it after the loop; the else clause makes the intent explicit and removes the bookkeeping. It’s genuinely useful in search loops — and genuinely confusing if the loop body is long, because the else sits far from the break it pairs with. Use it when the loop is short enough that the pairing is obvious.

The gotcha: the name is a historical mistake. else here has nothing to do with the loop condition being false — it fires on normal completion, and is skipped by break. If a loop has no break at all, its else always runs, which means the else is pointless. Only pair loop-else with a break.


match / case: structural pattern matching

Added in Python 3.10 (PEP 634), match is not a C-style switch. It compares a subject against a series of patterns that describe shape and structure, and can bind pieces of the subject to variables as it matches. That makes it ideal for dispatching on parsed data — commands, JSON-like dicts, tagged tuples, class instances.

Start with a literal-and-capture example: dispatching a parsed command where the first word selects the action.

def run(command: str) -> str:
    match command.split():
        case ["go", direction]:                 # capture: binds `direction`
            return f"Moving {direction}"
        case ["drop", *items]:                  # star pattern: rest into a list
            return f"Dropping {', '.join(items)}"
        case ["quit" | "exit"]:                 # or-pattern
            return "Goodbye"
        case []:
            return "Say something."
        case _:                                 # wildcard: the default
            return f"I don't understand: {command!r}"

Each case is a pattern. ["go", direction] matches a two-element list whose first element is exactly "go" and binds the second to direction. *items captures the remaining elements. The | combines alternatives. And _ is the wildcard — it matches anything and, unlike a normal name, binds nothing.

Class patterns match by type and can pull out attributes positionally or by keyword. Combined with a guard (an if after the pattern), you get expressive dispatch:

from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

def area(shape) -> float:
    match shape:
        case Circle(radius=r):
            return 3.14159 * r * r
        case Rectangle(width=w, height=h) if w == h:   # guard: only squares
            return w * w
        case Rectangle(width=w, height=h):
            return w * h
        case _:
            raise ValueError(f"Unknown shape: {shape!r}")

Patterns are tested top to bottom and the first match wins, so put more specific patterns (the squared-Rectangle guard) before the general one. If no case matches and there’s no _, the match simply falls through and does nothing — there is no error, so include a wildcard when you want exhaustiveness.

The gotcha: a bare name in a pattern is a capture, not a comparison. Writing case status: does not test whether the subject equals a variable named status — it matches anything and rebinds status to the subject, shadowing your variable. To match against a named constant you must make it a dotted name so the parser treats it as a value: put the constant on a class or enum (case Color.RED:, case HTTPStatus.OK:) or a module (case status.ACTIVE:). This is the number-one match surprise, and it’s why enums pair so well with pattern matching.


Truthiness drives the flow

Every condition above ultimately asks “is this truthy?” — and in Python that question extends far beyond True/False. Objects have an inherent truth value: 0, 0.0, "", [], {}, set(), and None are all falsy; non-empty containers and non-zero numbers are truthy. So you write:

if items:                # truthy iff the list is non-empty
    process(items)

name = user_input or "anonymous"   # falls back when user_input is falsy

The and/or operators return one of their operands, not a boolean — a or b yields a if it’s truthy, otherwise b. That’s what makes or a clean default-value idiom, and both operators short-circuit (they stop evaluating as soon as the result is decided).

The gotcha: if items: and if items is not None: are not the same test. An empty list is falsy but very much not None. When “empty” and “absent” mean different things — a supplied-but-empty config versus no config at all — test is None explicitly. Reaching for truthiness there will silently treat empty as missing.


Comprehensions: control flow as an expression

When a loop exists only to build a list, dict, or set, a comprehension expresses the same control flow as a single expression — the for (and optional if filter) fold into the construction:

squares = [n * n for n in range(10)]
evens = [n for n in range(20) if n % 2 == 0]
lengths = {word: len(word) for word in words}

These read as “the value, for each item, where the condition holds” — the loop and its filter are still there, just rearranged into a declarative shape. Prefer a comprehension when the goal is to produce a collection; keep an explicit for loop when the goal is to perform side effects (printing, writing, mutating external state), because a comprehension built for its side effects is both slower and misleading to read. We cover comprehensions — and generator expressions, their lazy cousin — in depth in the data-structures post.


Choosing the right construct

Choosing the right construct
Situation Reach for
Pick one of several branches on plain conditions if / elif / else
Choose between two values inline x if cond else y
Do something for each element of an iterable for item in iterable
Need the position too enumerate(iterable)
Walk several sequences together zip(a, b)
Repeat a fixed number of times / arithmetic sequence range(...)
Loop until a condition changes while
“Did the search finish without finding anything?” loop else (with break)
Dispatch on the shape of data match / case
Build a collection from a loop comprehension

Key takeaways


Further reading