Control Flow
Branch with if/elif/else and match, repeat with for and while, and use guard clauses to keep both readable as they grow.
By the end of this lesson, you can
- Predict which branch of an if/elif/else chain runs, including using truthiness without an explicit comparison
- Explain what a loop's else clause actually checks, and use it for a search-then-handle-not-found pattern
- Choose between if/elif and match for a given branching problem, and state match's minimum Python version
- Refactor nested conditionals into guard clauses that return early
Why it matters
Every program beyond the most trivial has to decide what to do — branch on a condition, repeat
until something is true, stop early once an answer is found. Python’s control-flow tools cover all
of that, but two of them (for/while’s else clause, and truthiness) behave in ways that
surprise people coming from other languages, or coming to programming for the first time. Getting
comfortable with both now avoids re-learning them the hard way later.
Mental model
Statements run top to bottom by default; if/elif/else and match pick exactly one branch to
run instead of falling through all of them; for and while repeat a block, and both can end
early with break or skip to the next iteration with continue.
Truthiness is what lets if items: work without spelling out if len(items) > 0:. Python
treats a small, fixed set of values as falsy in a boolean context — everything else is
truthy:
| Value | Type |
|---|---|
| 0, 0.0 | int, float |
| "" (empty string) | str |
| [], {}, set() | empty collections |
| None | NoneType |
| False | bool |
Worked example: guard clauses, a surprising loop else, and match
def validate_age(age: int) -> str | None:
if age < 0:
return "Age cannot be negative."
if age > 150:
return "That doesn't look like a real age."
return None
def find_first_negative(numbers: list[int]) -> int | None:
for number in numbers:
if number < 0:
found = number
break
else:
found = None
return found
def describe_status(code: int) -> str:
match code:
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Server Error"
case _:
return "Unknown"
def main() -> None:
for age in [-5, 200, 30]:
error = validate_age(age)
print(f"{age}: {error or 'valid'}")
print(find_first_negative([4, 7, -2, 9]))
print(find_first_negative([4, 7, 2, 9]))
for code in [200, 404, 999]:
print(f"{code} -> {describe_status(code)}")
if __name__ == "__main__":
main()-5: Age cannot be negative.
200: That doesn't look like a real age.
30: valid
-2
None
200 -> OK
404 -> Not Found
999 -> Unknownvalidate_age is a guard clause in action: each invalid case returns immediately, so the
function never needs an else or nested if to express “otherwise, it’s fine.” error or 'valid' leans on truthiness directly — a non-empty error message is truthy and wins; None is
falsy, so 'valid' is used instead.
find_first_negative is the canonical use of a loop’s else: it runs only when the for finishes
without hitting break — see for/while…else for exactly
what that checks (it is not the same thing as an if/else, despite sharing the keyword).
describe_status uses match to compare one value against
several literal possibilities, ending in a wildcard case _: catch-all — a reasonable alternative
to an if/elif chain here, though not a requirement; either reads fine for this specific case.
Common mistakes
- Nesting an
ifinside anifinside anifinstead of using guard clauses — each level of nesting makes the “normal” logic harder to find; returning early for edge cases keeps it flat. - Reading a loop’s
elseas “if the condition was ever false,” by analogy withif/else. It actually means “the loop finished withoutbreak” — a completely different check. - Writing
if is_valid == True:instead ofif is_valid:— the comparison is redundant and, worse, actively wrong ifis_validcould ever be a truthy non-boolean value instead of literallyTrue.
Check your understanding
Or reveal the answer without checking
Answer:empty
An empty list is falsy, so the if branch is skipped and the else branch runs, printing 'empty'.
Or reveal the answer without checking
Answer:"done" — the loop body never runs, but it never hit break either
A for loop over an empty list never executes its body and never calls break, so its else clause still runs — 'done' prints.
Practice: warm-up
Predict, then check: what does bool("False") return? Explain why in one sentence, using the
truthiness rules above rather than guessing from the text inside the string.
Stuck? Reveal one hint at a time.
Hint 1
bool() converts its argument by asking "is this value truthy or falsy," not by reading what the string says.
Hint 2
The falsy-value list has exactly one string on it: "" (the empty string). Every other string, including "False", is not on that list.
Hint 3
"False" is a non-empty string, so it is truthy — bool("False") is True, even though the text inside it says the opposite.
Reveal the answer
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
bool("False") -> TrueThis is a genuinely common beginner trap: the content of the string has nothing to do with its
truthiness. Only "", the empty string, is falsy — every other string, no matter what it says, is
truthy.
Practice: apply it
Write a small validation pipeline: a function validate_username(name: str) -> list[str] that
returns a list of problem messages (empty list means valid) — check for empty input, length under
3, and length over 20, using guard clauses rather than nested ifs.
Or reveal the answer without checking
Answer:The empty-string check first, since an empty string is also technically too short and you want the more specific message
An empty string would also satisfy 'length under 3', so without ordering, the too-short message could mask the more specific and more useful empty-input message. Guard clauses are evaluated in order, so putting the more specific check first is what makes the messages accurate.
Summary
- Truthiness lets
if value:work directly — only0,0.0,"", empty collections,None, andFalseare falsy; everything else, including the string"False", is truthy. - A loop’s
elseclause runs when the loop finishes without hittingbreak— including the edge case of an empty iterable that never runs the loop body at all. It has nothing to do withif/else’s meaning of the word. matchcompares one subject against a sequence of patterns and requires Python 3.10+; for simple literal-value branching, it’s an alternative toif/elif, not a strict improvement.- Guard clauses (early returns for edge cases) keep the common-case logic at a shallow, readable
indentation level instead of nesting it inside
elseblocks.