A program that cannot make decisions can only process a single path, which is not much of a program. Python's fundamental decision statement is if/elif/else: the interpreter evaluates the first condition, runs the matching block if it is truthy, and skips the rest. Any number of elif branches can follow the initial if, but only the first matching branch runs. The optional else catches every case none of the explicit conditions matched.
Every condition is ultimately coerced to a boolean, which means "truthiness" matters. An empty list, an empty dict, the integer 0, the float 0.0, the empty string, and None are all falsy; almost everything else is truthy. Idiomatic Python writes if items: rather than if len(items) > 0: and if user is None: rather than if user == None:. These small preferences add up to code that other Python programmers recognise at a glance.
For one-liner decisions, Python offers the conditional expression value_if_true if condition else value_if_false. It is the same idea as the ternary operator in other languages, arranged so the "happy path" reads first. Use it when the branches are simple expressions — assigning a default, building a short label. When either branch contains a statement or takes more than a short line, fall back to a full if/else; nesting conditional expressions is almost always a mistake.
Python 3.10 added structural pattern matching with match/case. Unlike switch in other languages, match can destructure: it checks not only values but the shape of an object. Patterns can match literal values, sequences (with or without a rest-capture), mappings with specific keys, dataclasses by type and attributes, and can include a guard clause after if. It does not replace if/elif; it shines when you are dispatching on the kind of a value rather than a scalar threshold.
Two small habits keep decision code healthy. First, exit early: when an unexpected input must cause the function to stop, return or raise at the top, so the happy path below is flat instead of nested five levels deep. Second, name the condition: if if a > threshold and b < max and not disabled: appears more than once or is hard to read, move it into a small helper function whose name becomes the documentation.
Early return and guard clauses
A "guard clause" is a short if at the top of a function that handles an exceptional case and returns. Instead of wrapping the whole function body inside if valid:, write if not valid: return None or if not valid: raise ValueError(...). The rest of the function then assumes validity and stays at a single indent level, which is much easier to read.
match/case for shape-based dispatch
Use match when incoming data has a limited set of shapes: parsed command arguments, JSON events, AST nodes. The patterns read as documentation: case {"type": "click", "target": target}: simultaneously checks that the key exists and binds its value. The catch-all case _: handles anything not matched and is a good place to raise on unexpected input.
The statements and built-ins that make decision code clear.
| Tool | Purpose |
|---|---|
if / elif / elsestatement | Runs at most one branch out of several conditions. |
a if cond else bexpression | One-line ternary for picking a value. |
match / casestatement | Structural pattern matching (Python 3.10+). |
truthinessrule | Decides how non-boolean values behave in conditions. |
all() / any()built-ins | Summarise a whole iterable of conditions. |
operator modulestdlib | Function versions of <, ==, in, etc. for functional-style code. |
assertstatement | Guard for invariants during development; may be stripped in -O. |
try / exceptstatement | Decide on behaviour based on whether an operation raised. |
Making Decisions in Code code example
The example routes events with if/elif, uses a guard clause, then switches to match/case for a version of the same dispatch.
# Lesson: Making Decisions in Code
# Goal: compare if/elif/else with match/case on the same tiny event stream.
def classify_if(event: dict) -> str:
'''Classic if/elif dispatch with a guard clause for bad input.'''
if not isinstance(event, dict) or "type" not in event:
raise ValueError("event must be a dict with a 'type' key")
t = event["type"]
if t == "click":
return f"click on {event.get('target', '?')}"
elif t == "scroll":
return f"scroll {event.get('delta', 0):+d}"
elif t == "key" and event.get("key") == "Escape":
return "cancel"
else:
return "unknown event"
def classify_match(event: dict) -> str:
'''Same dispatch expressed with match/case patterns.'''
match event:
case {"type": "click", "target": target}:
return f"click on {target}"
case {"type": "scroll", "delta": delta}:
return f"scroll {delta:+d}"
case {"type": "key", "key": "Escape"}:
return "cancel"
case _:
return "unknown event"
# --- main script ---------------------------------------------------------
events = [
{"type": "click", "target": "login"},
{"type": "scroll", "delta": -120},
{"type": "key", "key": "Escape"},
{"type": "key", "key": "a"},
]
for ev in events:
print("if: ", classify_if(ev))
print("match:", classify_match(ev))
# conditional expression for a single-line default
username = ""
label = username if username else "(anonymous)"
print(label)
Look at both functions side by side:
1) classify_if() uses a guard clause so the rest runs at one indent.
2) Each elif tests a single condition; order matters for overlapping keys.
3) classify_match() destructures dicts in one step, no `.get()` dance.
4) A conditional expression picks a display label in one line.
Two practice snippets: one for early returns, one for match sequences.
# Example A: early return keeps the positive path flat
def price(quantity: int, unit: float) -> float:
if quantity < 0:
raise ValueError("quantity cannot be negative")
if quantity == 0:
return 0.0
return quantity * unit
# Example B: match against sequences with *rest capture
def head_tail(items: list[int]) -> str:
match items:
case []:
return "empty"
case [only]:
return f"single {only}"
case [first, *rest]:
return f"first={first}, rest_len={len(rest)}"
print(head_tail([]))
print(head_tail([42]))
print(head_tail([1, 2, 3, 4]))
These assertions pin down both dispatch styles.
assert classify_if({"type": "click", "target": "ok"}) == "click on ok"
assert classify_match({"type": "scroll", "delta": 10}) == "scroll +10"
assert classify_if({"type": "key", "key": "Escape"}) == "cancel"
assert classify_match({"type": "resize"}) == "unknown event"
try:
classify_if("oops")
except ValueError:
pass
else:
raise AssertionError("guard clause should have rejected the non-dict")
Running the script prints one line per event (both styles agree):
if: click on login
match: click on login
if: scroll -120
match: scroll -120
if: cancel
match: cancel
if: unknown event
match: unknown event
(anonymous)