Guard Clauses

Concepts covered: Guard Clauses

Most functions that handle real data spend their first lines deciding whether the input deserves any work. A payment amount can be missing or negative. It can also arrive in a currency the warehouse never converts. How those checks are arranged decides whether the function reads top to bottom or makes you track which if you are standing in.

The function syntax this section needs

A guard clause lives inside a function, so here is the slice of function syntax it relies on. The line def to_dollars(amount_cents, currency): names a block of code and the 2 values it receives, and the indented lines under it form its body. Writing to_dollars(1999, "USD") runs that body with amount_cents set to 1999 and currency set to "USD". The keyword return hands a value back to the caller and ends the call on the spot: no line below it runs for that call. A function that reaches the end of its body without a return gives back None.

The staircase

def to_dollars(amount_cents, currency):
    if amount_cents is not None:
        if amount_cents >= 0:
            if currency == "USD":
                return amount_cents / 100
            else:
                return "reject: not USD"
        else:
            return "reject: negative amount"
    else:
        return "reject: missing amount"

Read it the way the next engineer will. To find out what happens when amount_cents is None, you start at the if on line 2 and scan down to the else on line 10. The rule and its outcome sit 9 lines apart, and the only line doing real work, the division, is buried under 3 if statements. A fourth rule adds another step and re-indents everything inside it.

Flip each check and leave early

A guard clause inverts the test so it describes the bad case, and returns the moment that case is found. Each guard is a complete rule on 2 adjacent lines: the condition, then the outcome. Once every guard has passed, the code below them may assume the input is valid, so the main path sits at the function's first indentation level. Step through the flat version with 3 different calls.

def to_dollars(amount_cents, currency):
    if amount_cents is None:
        return "reject: missing amount"
    if amount_cents < 0:
        return "reject: negative amount"
    if currency != "USD":
        return "reject: not USD"
    return amount_cents / 100

print(to_dollars(1999, "USD"))
print(to_dollars(-50, "USD"))
print(to_dollars(None, "EUR"))
19.99
reject: negative amount
reject: missing amount
Nested checks
  • A rule's outcome sits in an else far below its condition
  • The main path moves 1 level right per check
  • A new check re-indents everything inside it
Guard clauses
  • A rule and its outcome sit on adjacent lines
  • The main path stays at the first indentation level
  • A new check is 2 lines added above the main path

Guard order is a dependency chain

Each guard may rely on the ones above it. The test amount_cents < 0 needs a value that can be ordered against 0, and None can't be, so the None check sits above it. The rule: every guard comes before any comparison that would fail on a value no earlier guard has ruled out. The None guard rules out exactly 1 value. It doesn't make amount_cents a number: a string such as "50" passes it and fails at < 0 the same way. Confirming that a value is a number takes a type check, which the last section of this lesson covers. In the animation, the call with None and "EUR" returned at the first guard, so the currency test never ran. Swap the first 2 guards and that protection is gone.

The first 2 guards have been swapped. What happens when this runs?

def to_dollars(amount_cents, currency):
    if amount_cents < 0:
        return "reject: negative amount"
    if amount_cents is None:
        return "reject: missing amount"
    if currency != "USD":
        return "reject: not USD"
    return amount_cents / 100

print(to_dollars(None, "USD"))

Guards reject, branches choose

Guards fit cases that stop the work: missing, malformed, out of range. When both outcomes are ordinary results, such as a domestic fee and an international fee, a plain if/else states the choice as it is, and turning one side into a fake guard hides that both sides are equal partners. A well-shaped function usually opens with a few guards and ends with a single short decision.

Writing the inverted test

Converting a staircase into guards means writing the opposite of each condition the staircase kept going on. Every comparison operator has an exact opposite, and the table below pairs them. The boundary value is where a wrong inversion hides: the staircase accepted amount_cents >= 0, so the guard must leave on amount_cents < 0. Writing <= 0 instead rejects a 0-cent amount, such as a fully discounted order, and nothing else, so every test that uses ordinary amounts still passes.

Staircase continues whenGuard returns when
`amount_cents is not None``amount_cents is None`
`amount_cents >= 0``amount_cents < 0`
`currency == "USD"``currency != "USD"`
`total > 50``total <= 50`

About this lesson

This section is part of the Control Flow: Intermediate lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.