PYTHON / CONTROL FLOW
Guard clauses and flattening deep nesting
Rewrite deeply nested if-blocks as early-exit guard clauses in Python functions and loops without changing behaviour.
What you will learn
- Invert a precondition and exit immediately instead of indenting the happy path
- Use return, raise, continue or break as the guard's exit
- Rely on accumulated invariants: code after a guard assumes the guard did not fire
- Keep if/else when both branches do real work; guards are only for exits
Understanding Guard clauses and flattening deep nesting
A function usually has a handful of conditions that must hold before the real work makes sense: the argument is not None, the list is not empty, the country is one you ship to. If you express each of those as `if condition_holds:` and put the work inside, every new condition pushes the interesting code one level deeper, and the value you return ends up many lines away from the test that caused it. A guard clause flips this around: you test for the condition that is wrong, exit right there with `return` or `raise`, and leave the rest of the body at one indentation level.
The mental model is accumulated invariants. Every guard you pass adds a fact that the remaining code may take for granted, so by the time you reach the bottom of the function you are looking at code that only runs for valid input, and you no longer have to hold a stack of open `if` blocks in your head to know which case you are in. This is why guard order matters: `if data is None: return 0` must come before anything that calls `len(data)`, because the later line is only safe thanks to the earlier exit. Reordering guards is not a cosmetic change.
In Python the pressure to flatten is unusually concrete, because indentation is syntax. Four levels of nesting cost sixteen columns before your code even starts, which pushes lines past a readable width and makes a misplaced `dedent` a silent logic change rather than a compile error. The same technique works in loops, where `continue` is the guard's exit instead of `return`, and in validation, where `raise` is. Flattening only applies when the rejected case exits; if both branches do substantial work, a real `if`/`else` is still the honest structure.
def cost_nested(order):
if order is not None:
if order.get('items'):
if order.get('country') == 'NL':
weight = sum(i['kg'] for i in order['items'])
if weight <= 20:
if weight < 10:
return 'cost: 4.95'
return 'cost: 9.95'
return 'too heavy'
return 'ships to NL only'
return 'empty cart'
return 'no order'
def cost_flat(order):
if order is None:
return 'no order'
if not order.get('items'):
return 'empty cart'
if order.get('country') != 'NL':
return 'ships to NL only'
weight = sum(i['kg'] for i in order['items'])
if weight > 20:
return 'too heavy'
if weight < 10:
return 'cost: 4.95'
return 'cost: 9.95'
orders = [
None,
{'country': 'NL', 'items': []},
{'country': 'DE', 'items': [{'kg': 2}]},
{'country': 'NL', 'items': [{'kg': 25}]},
{'country': 'NL', 'items': [{'kg': 3}]},
{'country': 'NL', 'items': [{'kg': 12}]},
]
for o in orders:
a, b = cost_nested(o), cost_flat(o)
print(a, '|', b, '|', a == b)Exiting early on the invalid case keeps the valid case unindented, and every passed guard becomes a fact the rest of the code can assume.
Worked examples
continue as a loop guard
Skipping unusable input at the top of a loop body so the parsing code stays at one level.
rows = ['1,alice,30', '', '# header skipped', '2,bob', '3,carol,41']
for raw in rows:
line = raw.strip()
if not line:
continue
if line.startswith('#'):
continue
parts = line.split(',')
if len(parts) != 3:
print('malformed:', line)
continue
_, name, age = parts
print(name, 'is', age)Example explained
Line 1`if not line: continue` rejects blank lines, so no later line has to re-check for emptiness.
Line 2The comment guard runs after stripping, otherwise a leading space would hide the `#`.
Line 3`len(parts) != 3` prints and then continues; without the `continue` the unpacking below would raise ValueError.
Line 4The final two lines run only for rows that survived all three guards, so unpacking is safe.
raise as the exit
Validating arguments with guards that raise, keeping the calculation on the last line.
def apply_discount(price, percent):
if not isinstance(price, (int, float)):
raise TypeError('price must be numeric')
if price < 0:
raise ValueError('price must not be negative')
if not 0 <= percent <= 100:
raise ValueError('percent must be 0..100')
return round(price * (1 - percent / 100), 2)
print(apply_discount(80, 25))
for args in [('80', 10), (-5, 10), (80, 150)]:
try:
apply_discount(*args)
except (TypeError, ValueError) as err:
print(type(err).__name__, err, sep=': ')Example explained
Line 1The type guard runs first because `price < 0` would raise a confusing TypeError on the string '80'.
Line 2`raise` ends the call just as `return` does, so no `else` is needed after any guard.
Line 3`not 0 <= percent <= 100` is the inverted form of the valid range, which is what a guard tests for.
Line 4The arithmetic on the last line never sees a bad value, so it needs no defensive checks of its own.
Important notes
An early `return` skips everything below it, including trailing cleanup lines; put cleanup in `with` or `try/finally` so a guard cannot bypass it.
In a generator function `return` only stops iteration, so guards there end the stream rather than producing a value.
Common mistakes
Writing the guard's message but forgetting the `return` or `continue`, so execution falls through into the main body and crashes on the very data the guard detected.
Inverting a compound condition wrongly: `if a and b:` becomes `if not a or not b: return`, not `if not a and not b: return`, which otherwise lets half the invalid inputs through.
Reordering guards while flattening, for example testing `len(x)` before `x is None`, which turns a clean early return into a TypeError.
Try it yourself
Change, predict, then run
Take a function `describe(user)` that nests `if user is not None:`, `if user.get('email'):`, and `if user['age'] >= 18:` around a single print, rewrite it with three guard clauses, and check that None, a user with no email, and a 15-year-old all produce the same strings as before.
Open the Python workspaceCheck your understanding
A function begins with `if data is None: return 0` followed by `if len(data) == 0: return 0`. Why is swapping these two guards not a safe refactor?
- The second guard only works because the first one already removed None, so it would raise TypeError
- Python requires guard clauses to be sorted by the type of value they test
- Both guards return 0, so swapping them makes the second one unreachable
- Guards are independent checks, so the order never affects behaviour, only style
Show answer
Each passed guard is an invariant the following code depends on: `len(None)` raises TypeError, so the emptiness check is only valid after the None check. The last option is tempting because both guards return the same value and look interchangeable, but the checks are not independent, only their return values are.