Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 4

Flow Control

Statements that direct execution, and the idioms and traps around them.

4.1 Conditional Statements

Python's conditional keywords are if, elif (optional, repeatable) and else (optional). Python has no switch/case — elif chains fill that role. At most one branch in the whole chain runs — the first one whose condition is truthy.

Truthiness — a condition doesn't have to be a bool; Python evaluates any object for truthiness. Falsy values: False, None, 0, 0.0, '', [], {}, set(), () — everything else is truthy.

In [1]7 lines
x = 15
if x < 10:
    print("small")
elif x < 20:
    print("medium")   # medium -> first true condition wins, rest are skipped
else:
    print("large")
medium
In [2]2 lines
for value in [0, "", [], None, "hi", "False", [1], 5]:
    print(value, "->", "truthy" if value else "falsy")   # "False" is truthy! it's a non-empty str
0 -> falsy
 -> falsy
[] -> falsy
None -> falsy
hi -> truthy
False -> truthy
[1] -> truthy
5 -> truthy

Common mistake — a sequence of standalone if statements is not the same as an elif chain: each if is evaluated independently and can all run, whereas an elif chain only ever runs one branch.

In [3]10 lines
x = 15
if x > 5:
    print("a")    # a -> independent if, runs
if x > 10:
    print("b")    # b -> also independent, also runs

if x > 5:
    print("c")    # c -> first true branch in the chain
elif x > 10:
    print("d")    # skipped, even though x > 10 is also true
a
b
c

Idiom — a long elif chain that just maps a key to an action can often be replaced with a dictionary of callables, dispatching in a single lookup instead of a chain of comparisons:

In [4]12 lines
def handle_a():
    return "handling a"

def handle_b():
    return "handling b"

def handle_default():
    return "no handler for this key"

key = "b"
result = {"a": handle_a, "b": handle_b}.get(key, handle_default)()
print(result)   # handling b -> dict.get() finds "b"'s handler and calls it
handling b

In practice — this is conceptually what a web framework's router does. Flask's and Django's URL routing maps a path to a handler function via a lookup, not a chain of if url == "/a": ... elif url == "/b": ... — the dict-dispatch idiom above is the same underlying idea in miniature, just without the pattern-matching a real router also supports.

4.2 Loops

Python has two loop constructs:

Loop Use when
for iterating over a known sequence/iterable (list, string, range, ...)
while repeating until a condition becomes falsy — no fixed number of iterations

One sub-section per loop below.

4.2.1 For Loops

for iterates over the items of an iterable directly — no manual index/counter needed. range() (2.1) is commonly paired with it to loop a fixed number of times.

In [5]6 lines
for fruit in ["apple", "banana", "cherry"]:
    print(fruit)         # apple banana cherry, one per line

for i in range(5):
    print(i, end=" ")     # 0 1 2 3 4
print()
apple
banana
cherry
0 1 2 3 4 

range() also accepts a start and a step (which can be negative): range(stop), range(start, stop), range(start, stop, step).

In [6]3 lines
print(list(range(2, 10)))       # [2, 3, 4, 5, 6, 7, 8, 9]       -> start at 2, stop before 10
print(list(range(0, 10, 2)))    # [0, 2, 4, 6, 8]                 -> step by 2
print(list(range(10, 0, -1)))   # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] -> counts down
[2, 3, 4, 5, 6, 7, 8, 9]
[0, 2, 4, 6, 8]
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

Common mistake — mutating a list (2.3.1) while iterating over it directly shifts the remaining indices, silently skipping elements:

In [7]5 lines
nums = [1, 2, 3, 4]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)    # mutates the list being iterated -> indices shift mid-loop
print(nums)                 # [1, 3, 4] -> 4 wasn't removed, even though it's even
[1, 3]

Fix — iterate over a copy of the list (nums[:]), or rebuild the result with a list comprehension instead of mutating in place:

In [8]9 lines
nums = [1, 2, 3, 4]
for n in nums[:]:          # iterate over a copy -> nums itself can be mutated safely
    if n % 2 == 0:
        nums.remove(n)
print(nums)                  # [1, 3] -> both evens correctly removed

nums = [1, 2, 3, 4]
nums = [n for n in nums if n % 2 != 0]   # rebuild instead of mutating
print(nums)                  # [1, 3]
[1, 3]
[1, 3]

4.2.2 While Loops

while repeats as long as its condition stays truthy. The loop body must eventually make the condition falsy — commonly by updating a variable the condition depends on — or the loop never stops.

In [9]4 lines
n = 3
while n > 0:
    print(n)   # 3 2 1
    n -= 1      # without this, the condition n > 0 never becomes False -> infinite loop
3
2
1

A common pattern combines while True (loop forever) with break to exit only when a specific condition is met inside the body — e.g. reading items until a sentinel value appears:

In [10]8 lines
items = ["apple", "banana", "STOP", "cherry"]
i = 0
while True:
    item = items[i]
    if item == "STOP":
        break             # sentinel found -> exit the infinite loop
    print(item)            # apple banana
    i += 1
apple
banana

In practice — a message-queue worker's main loop. A background worker polling RabbitMQ, Kafka, or SQS for the next message runs almost exactly this shape: loop forever, and break (or, more often, just keep looping — a worker process usually never intentionally exits) only when a shutdown signal is received rather than a fixed number of iterations being known in advance.

4.3 Loop Control Statements

Three statements alter the normal flow inside a loop body:

Statement Effect
break exits the loop immediately, skipping any remaining iterations
continue skips the rest of the current iteration, moves on to the next one
pass does nothing — a placeholder where a statement is syntactically required
In [11]5 lines
for n in range(10):
    if n == 5:
        break            # stop the loop entirely
    print(n, end=" ")     # 0 1 2 3 4
print()
0 1 2 3 4 
In [12]5 lines
for n in range(6):
    if n % 2 == 0:
        continue          # skip even numbers, go straight to the next iteration
    print(n, end=" ")      # 1 3 5
print()
1 3 5 
In [13]4 lines
for n in range(3):
    if n == 1:
        pass    # placeholder -> does nothing, execution just falls through
    print(n)     # 0 1 2 -> pass had no effect on the output
0
1
2

pass is also used to stub out an empty function/class body while keeping it syntactically valid (6, 10 cover those).

Python has no labeled break/continue for jumping out of a specific nested loop directly (unlike Java or C) — the usual workarounds are a flag variable checked after the inner loop, or wrapping the nested loops in a function and using return in place of break.

4.4 The else Clause on Loops and try

else isn't exclusive to if — for, while and try all accept an optional else block too. In every case it means the same thing: "this block ran to completion without an early exit."

Statement else runs when else is skipped when
for / while the loop finishes normally (iterable exhausted / condition turned falsy) a break inside the loop fired
try the try block raised no exception an exception was raised in the try block

Common mistake — it's tempting to assume else runs because of break; it's the opposite: break is exactly what prevents it from running.

In [14]17 lines
numbers = [4, 7, 9, 12, 15]

target = 9
for n in numbers:
    if n == target:
        print(f"found {target}")   # found 9 -> break fires, else is skipped
        break
else:
    print(f"{target} not in list")

target = 100
for n in numbers:
    if n == target:
        print(f"found {target}")
        break
else:
    print(f"{target} not in list")   # 100 not in list -> loop exhausted, no break -> else runs
found 9
100 not in list

Same idiom with while — check whether a number is prime by trying divisors until one divides it evenly:

In [15]9 lines
n = 11
divisor = 2
while divisor < n:
    if n % divisor == 0:
        print(n, "is not prime, divisible by", divisor)
        break
    divisor += 1
else:
    print(n, "is prime")   # runs -> no divisor found, loop completed normally, no break
11 is prime

Before the try row above makes sense, here's the plain-language version: code that might fail goes inside try. If it raises an exception (an error object signaling something went wrong), execution jumps immediately to a matching except block instead of crashing the program; if nothing goes wrong, every except block is skipped entirely.

try follows the same rule as loops, plus a finally block that runs no matter what — cleanup code (closing a file, releasing a lock) belongs there. (6.1 covers exactly what happens when a return inside try meets a finally block.) Full exception-handling mechanics — raising, custom exceptions, chaining — are covered in Chapter 7; this is just the else/finally half of the picture:

In [16]12 lines
def parse(value):
    try:
        result = int(value)
    except ValueError:
        print(f"{value!r} is not a valid int")   # runs only if int() raised
    else:
        print(f"parsed: {result}")                 # runs only if int() raised nothing
    finally:
        print("done attempting parse")             # always runs, either way

parse("42")    # parsed: 42          / done attempting parse
parse("abc")   # 'abc' is not a valid int / done attempting parse
parsed: 42
done attempting parse
'abc' is not a valid int
done attempting parse

In practice — retry logic in an API client. Catching a network error in except, running success-only logic in else, and always logging "attempt finished" in finally is exactly the shape of a real HTTP client's retry loop — try the request, handle failure differently from success, and always record that the attempt happened either way.

4.5 Structural Pattern Matching (match/case)

match/case is Python's answer to a switch statement, but considerably more powerful: instead of only comparing a value against literals, a case can check the shape of a value (unpack a sequence, pull specific fields out of a mapping or an object) in the same step as deciding whether it matches at all.

match subject:
    case pattern1:
        ...
    case pattern2:
        ...
    case _:
        ...   # wildcard - matches anything not already caught above

subject is evaluated once; each case is tried top to bottom, and the first pattern that matches runs its block — the rest are skipped, exactly like an elif chain (4.1).

In [17]13 lines
def http_status(code):
    match code:
        case 200:
            return "OK"
        case 404:
            return "Not Found"
        case 500:
            return "Server Error"
        case _:
            return "Unknown"

print(http_status(200))   # OK
print(http_status(999))   # Unknown -> falls through to the wildcard
OK
Unknown

OR patterns (|) — several literal patterns can share one case block, matching if the subject equals any of them:

In [18]11 lines
def describe(code):
    match code:
        case 200 | 201 | 204:
            return "Success"
        case 400 | 404 | 422:
            return "Client Error"
        case _:
            return "Other"

print(describe(201))   # Success
print(describe(404))   # Client Error
Success
Client Error

Guard clauses (case pattern if condition:) — a pattern can be narrowed with an extra if condition, checked only once the pattern itself has already matched. A bare name in a pattern (like x below) captures whatever the subject was — the "Common mistake" later in this section covers exactly what that means and where it bites:

In [19]15 lines
def classify(n):
    match n:
        case x if x < 0:
            return "negative"
        case 0:
            return "zero"
        case x if x % 2 == 0:
            return "positive even"
        case _:
            return "positive odd"

print(classify(-5))   # negative
print(classify(0))      # zero
print(classify(4))      # positive even
print(classify(7))      # positive odd
negative
zero
positive even
positive odd

Destructuring a sequence — a pattern that looks like a list/tuple literal matches a sequence of that exact length, binding names to its elements; *name (exactly like 5.5.3's extended tuple unpacking) collects "everything else" into a list:

In [20]14 lines
def handle_command(command):
    match command.split():
        case ["go", direction]:
            return f"going {direction}"
        case ["take", *items]:
            return f"taking {items}"
        case []:
            return "no command"
        case _:
            return "unknown command"

print(handle_command("go north"))            # going north
print(handle_command("take sword shield"))   # taking ['sword', 'shield']
print(handle_command(""))                       # no command -> "".split() is []
going north
taking ['sword', 'shield']
no command

Destructuring a mapping — a pattern that looks like a dict literal matches any mapping that has at least the listed keys (extra keys in the subject are ignored, unlike the sequence pattern's exact-length requirement above), binding names to their values:

In [21]11 lines
def handle_event(event):
    match event:
        case {"type": "click", "x": x, "y": y}:
            return f"click at ({x}, {y})"
        case {"type": "key", "key": key}:
            return f"key pressed: {key}"
        case _:
            return "unknown event"

print(handle_event({"type": "click", "x": 10, "y": 20}))   # click at (10, 20)
print(handle_event({"type": "key", "key": "Enter"}))          # key pressed: Enter
click at (10, 20)
key pressed: Enter

Matching against a class — ClassName(attr=pattern, ...) matches any instance of ClassName (checked with isinstance(), 12.1) whose named attributes match the given sub-patterns, combining a type check with pulling specific fields out of the object in one step:

In [22]22 lines
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

def locate(point):
    match point:
        case Point(x=0, y=0):
            return "origin"
        case Point(x=0, y=y):
            return f"on y-axis at {y}"
        case Point(x=x, y=0):
            return f"on x-axis at {x}"
        case Point():
            return "somewhere else"
        case _:
            return "not a point"

print(locate(Point(0, 0)))   # origin
print(locate(Point(0, 5)))    # on y-axis at 5
print(locate(Point(3, 0)))    # on x-axis at 3
print(locate(Point(3, 5)))    # somewhere else
origin
on y-axis at 5
on x-axis at 3
somewhere else

Common mistake — a bare name in a case is a capture pattern, not a comparison. case expected: does not check whether the subject equals the existing variable expected — a bare name always matches, unconditionally, and just binds that name (locally) to whatever the subject was. This silently shadows the outer expected instead of comparing against it:

In [23]9 lines
expected = 42

def check(value):
    match value:
        case expected:   # NOT a comparison - this always matches, and creates a NEW local `expected`
            return f"matched (local expected is now {expected})"

print(check(999))                    # matched (local expected is now 999) -> "matched" ANY value
print("outer expected:", expected)   # 42 -> the outer name was never actually touched
matched (local expected is now 999)
outer expected: 42

Python's compiler does catch the most obvious form of this mistake — a bare-name pattern immediately followed by another case makes that later case provably unreachable, and this specific shape is rejected at compile time rather than left as a silent bug:

In [24]12 lines
bad_code = """
def check(value):
    match value:
        case expected:
            return "matched"
        case _:
            return "no match"
"""
try:
    compile(bad_code, "<test>", "exec")
except SyntaxError as e:
    print("SyntaxError:", e)   # name capture 'expected' makes remaining patterns unreachable
SyntaxError: name capture 'expected' makes remaining patterns unreachable (<test>, line 4)

The fix — compare with a guard instead of a bare name:

In [25]9 lines
def check_fixed(value):
    match value:
        case v if v == expected:   # explicit comparison against the OUTER expected
            return "matched!"
        case _:
            return "no match"

print(check_fixed(999))   # no match
print(check_fixed(42))      # matched!
no match
matched!

In practice — routing structured messages. A message-queue consumer or webhook handler that receives events shaped like {"type": "payment.succeeded", ...} / {"type": "payment.failed", ...} is a natural fit for the mapping-pattern style shown above — matching on "type" and destructuring the rest of the payload in the same case, instead of a chain of if event["type"] == ... checks.