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.
x = 15
if x < 10:
print("small")
elif x < 20:
print("medium") # medium -> first true condition wins, rest are skipped
else:
print("large")
medium
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.
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:
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.
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).
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:
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:
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.
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:
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 |
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
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
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.
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:
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:
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).
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:
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:
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:
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:
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:
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:
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:
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:
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.