CMPUT-174 · Lesson 5 of 12 · 26 min

Control flow: conditionals

The programs so far have run every statement in order, top to bottom. Real problems need choices: charge a different fare for children, reject an invalid percentage, warn when a tank is nearly empty. Conditional statements let a program choose which statements to run based on the values it is working with. This lesson covers Python's if, elif and else statements, how indentation defines blocks, nested conditions, the conditional expression, the match statement, and the important skill of testing decisions at their boundaries. Together with loops in lesson 6, conditionals are what turn a list of instructions into an algorithm that can respond to its input.

The if statement and blocks

An if statement runs a block of statements only when a condition is true. The condition is any expression that produces a Boolean, or a value Python can treat as one. The line ends with a colon, and the statements that belong to the if are indented beneath it, conventionally by four spaces. When the indentation returns to the earlier level, the block has ended and the program continues with the next statement regardless of the condition.

An if statement with a two-statement block
temperature = -18

if temperature < -10:
    print("Extreme cold: limit time outdoors.")
    print("Cover exposed skin.")
print("Forecast shown.")      # not indented: always runs
Output
Extreme cold: limit time outdoors.
Cover exposed skin.
Forecast shown.

Indentation in Python is not decoration; it is part of the language's grammar. Many languages, such as C and Java, mark blocks with braces and treat indentation as optional style. Python's designers chose to make indentation mandatory so that the way code looks always matches what it does. The price is that you must be consistent: mixing tabs and spaces, or indenting one line by three spaces and the next by four, produces an IndentationError. Configure your editor to insert four spaces when you press Tab, which IDLE, Thonny and Visual Studio Code do by default for Python files.

An if statement can have an else part, which runs when the condition is false, so exactly one of the two blocks runs. For more than two choices, add elif branches, short for "else if". Python tests the conditions from top to bottom and runs the block for the first condition that is true, then skips the rest. If none is true, the else block runs, if there is one.

A flowchart for a grade calculation. A mark enters a chain of decision diamonds: mark at least 80 leads to grade A; otherwise mark at least 70 leads to B; otherwise at least 60 leads to C; otherwise at least 50 leads to D; otherwise F. All branches join and continue to print the grade.
An if-elif-else chain tests conditions in order and takes the first true branch
An if-elif-else chain for letter grades
def letter_grade(mark):
    """Return a letter grade for a percentage mark from 0 to 100."""
    if mark >= 80:
        return "A"
    elif mark >= 70:
        return "B"
    elif mark >= 60:
        return "C"
    elif mark >= 50:
        return "D"
    else:
        return "F"

for m in [95, 80, 79.9, 70, 65, 50, 49.5, 0]:
    print(f"{m:5} -> {letter_grade(m)}")
Output
   95 -> A
   80 -> A
 79.9 -> B
   70 -> B
   65 -> C
   50 -> D
 49.5 -> F
    0 -> F

Order matters in a chain. Because Python takes the first true condition, the test mark >= 70 only needs to check the lower limit: by the time it is reached, we already know the mark is below 80. If the conditions were written in the opposite order, starting with mark >= 50, every passing mark would get a D, a logic error that produces no message. When the branches of a chain are ranges, either test from highest to lowest or write each range in full, for example 70 <= mark < 80, so that the order no longer matters.

The same tests in the wrong order give wrong answers silently
def letter_grade_wrong_order(mark):
    if mark >= 50:
        return "D"
    elif mark >= 60:
        return "C"
    elif mark >= 80:
        return "A"
    else:
        return "F"

print([letter_grade_wrong_order(m) for m in (95, 72, 55, 30)])   # every pass is a D
Output
['D', 'D', 'D', 'F']

Designing conditions

Many decisions combine several facts. Python's and, or and not, from lesson 4, combine conditions; parentheses make the grouping explicit. Suppose a transit system charges a reduced fare to riders under 13 or 65 and over, but children under 6 ride free. A first attempt often gets the order or the grouping wrong. Writing the rules as a small decision table first, listing every combination of facts and the required outcome, makes the logic easy to check before writing code.

Decision table for an illustrative transit fare
AgeFare categoryFare in this example
0 to 5Free0.00
6 to 12Child2.00
13 to 64Adult3.50
65 and overSenior2.00
Negative or over 130Invalid inputRejected
Implementing the decision table, validating input first
def fare(age):
    """Return the fare for a rider of the given age (illustrative prices)."""
    if age < 0 or age > 130:
        raise ValueError(f"implausible age: {age}")
    if age <= 5:
        return 0.00
    elif age <= 12 or age >= 65:
        return 2.00
    else:
        return 3.50

for age in [0, 5, 6, 12, 13, 64, 65, 90]:
    print(f"age {age:3d}: {fare(age):.2f}")
for age in [-1, 200]:
    try:
        fare(age)
    except ValueError as e:
        print("rejected:", e)
Output
age   0: 0.00
age   5: 0.00
age   6: 2.00
age  12: 2.00
age  13: 3.50
age  64: 3.50
age  65: 2.00
age  90: 2.00
rejected: implausible age: -1
rejected: implausible age: 200

Two design habits appear in this function. First, it validates its input before doing anything else. Checking for impossible values at the start, sometimes called a guard clause, keeps the main logic simple and stops bad data from producing plausible-looking nonsense later. Second, the test values were chosen at the boundaries of each category, 5 and 6, 12 and 13, 64 and 65, because boundaries are exactly where off-by-one mistakes, such as writing < where <= was intended, hide.

That second habit has a name: boundary value testing. For any condition that splits inputs into ranges, test the values just below, on and just above each boundary. For letter_grade, that means 79.9, 80 and 80.1, and so on down the scale. It is one of the most productive testing techniques there is, because a large fraction of real bugs are boundary bugs. Lesson 11 builds it into automated tests.

Worked problem

Finding a boundary bug

A store gives free shipping on orders of 50 dollars or more. The code says: if total > 50: shipping = 0, else: shipping = 7.99. Which test reveals the bug, and what is the fix?

Show solution
  1. The rule says 50 dollars or more, so a total of exactly 50.00 should ship free.
  2. With total > 50, a total of 50.00 gives shipping 7.99: the boundary value exposes the bug.
  3. Totals of 49.99 and 50.01 both behave correctly, so tests that avoid the exact boundary would miss it.
  4. Fix the condition to total >= 50.

Answer: Test exactly 50.00; change > to >=.

Nested conditions

A conditional block can contain another conditional. Nesting is natural when a second question only makes sense after the first is answered, but deep nesting quickly becomes hard to read. Two techniques keep it under control: combining conditions with and, and returning early from a function once the answer is known, so the remaining code does not need to be indented further.

Flattening nested conditions with early returns
# Nested version: correct but hard to follow
def can_borrow_nested(is_member, overdue_items, fines):
    if is_member:
        if overdue_items == 0:
            if fines < 10:
                return True
            else:
                return False
        else:
            return False
    else:
        return False

# Flattened version with early returns: same results, easier to read
def can_borrow(is_member, overdue_items, fines):
    if not is_member:
        return False
    if overdue_items > 0:
        return False
    return fines < 10

cases = [(True, 0, 0), (True, 0, 12), (True, 2, 0), (False, 0, 0)]
for c in cases:
    print(c, can_borrow_nested(*c), can_borrow(*c))
Output
(True, 0, 0) True True
(True, 0, 12) False False
(True, 2, 0) False False
(False, 0, 0) False False

The test compares both versions on the same inputs, a cheap way to check that a rewrite did not change behavior, which programmers call a refactoring. The asterisk in can_borrow(*c) unpacks the tuple c into three separate arguments; lesson 9 explains tuples.

Conditional expressions

For a simple choice between two values, Python has a compact conditional expression: value_if_true if condition else value_if_false. It is an expression, not a statement, so it can appear inside other expressions, such as an f-string or an assignment. Use it when both choices are short; for anything longer, an ordinary if statement is clearer.

Conditional expressions for short choices
for n in [1, 2, 7]:
    print(f"{n} item{'s' if n != 1 else ''} in your cart")

reading = 38.4
status = "fever" if reading >= 38.0 else "normal"
print("Temperature:", status)

values = [12, -3, 0, 8]
print([abs(v) if v < 0 else v for v in values])
Output
1 item in your cart
2 items in your cart
7 items in your cart
Temperature: fever
[12, 3, 0, 8]

The match statement

Python 3.10 added a match statement for choosing among many specific values or shapes of data. Each case lists a pattern; the first matching case runs, and case _ matches anything, like a final else. For simple value choices, match reads more cleanly than a long if-elif chain, and it becomes powerful when matching structured data, which CMPUT-274 explores. Because older Python versions do not have it, some code you read will use if-elif chains where match would now fit.

A match statement interpreting text commands for a small game
def describe(command):
    match command.lower().split():
        case ["quit"] | ["exit"]:
            return "Goodbye."
        case ["go", direction] if direction in ("north", "south", "east", "west"):
            return f"You walk {direction}."
        case ["go", _]:
            return "You cannot go that way."
        case ["take", item]:
            return f"You pick up the {item}."
        case []:
            return "Say something."
        case _:
            return "I do not understand."

for cmd in ["go north", "GO up", "take lamp", "exit", "", "dance wildly"]:
    print(f"{cmd!r:15} -> {describe(cmd)}")
Output
'go north'      -> You walk north.
'GO up'         -> You cannot go that way.
'take lamp'     -> You pick up the lamp.
'exit'          -> Goodbye.
''              -> Say something.
'dance wildly'  -> I do not understand.

This example also previews the capstone in lesson 12, where you can build a small text adventure. The patterns match lists of words: ["go", direction] matches any two-word command starting with go and binds the second word to the name direction, and the if after it, called a guard, adds an extra condition.

Truthiness and common traps

An if condition does not have to be a Boolean. Python treats zero, empty strings, empty lists, empty dictionaries and None as false, and nearly everything else as true. So if items: means "if the list is not empty". This is idiomatic and concise, but it can hide bugs when zero is a legitimate value: if a sensor reading of 0 is valid, if reading: will wrongly treat it as missing. In such cases test explicitly, for example if reading is not None:.

Truthiness can treat a legitimate zero as missing
def report(reading):
    if reading:                          # treats 0 as missing
        sloppy = f"value {reading}"
    else:
        sloppy = "missing"
    if reading is not None:              # only None means missing
        careful = f"value {reading}"
    else:
        careful = "missing"
    return sloppy, careful

for r in [12.5, 0, None]:
    print(r, report(r))
Output
12.5 ('value 12.5', 'value 12.5')
0 ('missing', 'value 0')
None ('missing', 'missing')

Another trap is writing a comparison against several values incorrectly. The condition if answer == "y" or "yes": is always true, because Python reads it as (answer == "y") or ("yes"), and the non-empty string "yes" is truthy. The correct forms are answer == "y" or answer == "yes", or, more neatly, answer in ("y", "yes").

A classic condition bug and its fix
answer = "no"
print(bool(answer == "y" or "yes"))     # always True: a classic bug
print(answer in ("y", "yes"))           # correct
Output
True
False
Choosing the right conditional construct
SituationPreferAvoid
Several ranges of one valueif-elif-else from highest to lowest, or full rangesUnordered tests that overlap
Invalid inputA guard clause at the start that raises an error or returnsLetting bad values flow into calculations
Two short alternatives for a valueA conditional expressionA four-line if-else for one assignment
Many specific values or shapesmatch, or a dictionary lookup (lesson 9)A very long if-elif chain
Comparing with several valuesx in (a, b, c)x == a or b or c
Zero or empty is valid datais not None or an explicit comparisonRelying on truthiness

Common mistakes

  • MistakeForgetting the colon at the end of an if, elif or else line.

    InsteadEvery such line ends with a colon, and the block below is indented.

  • MistakeMixing tabs and spaces, or indenting inconsistently.

    InsteadUse four spaces per level; set your editor to insert spaces.

  • MistakeWriting range tests in an order where an early branch captures values meant for later ones.

    InsteadTest from highest to lowest, or write each range in full.

  • MistakeTesting only typical values.

    InsteadTest just below, on and just above every boundary.

  • MistakeWriting if x == 1 or 2:

    InsteadWrite if x == 1 or x == 2:, or if x in (1, 2):.

  • MistakeUsing if value: when 0 is a valid value.

    InsteadTest explicitly, for example if value is not None:.

Summary and key terms

  • if runs a block only when its condition is true; else and elif add alternatives.
  • Indentation defines blocks in Python and must be consistent.
  • An if-elif-else chain runs only the first branch whose condition is true, so order matters.
  • Validate input early with guard clauses and keep nesting shallow with early returns.
  • Boundary value testing checks just below, on and just above every decision boundary.
  • Conditional expressions choose between two short values; match chooses among many values or patterns.
  • Truthiness is concise but can mistake zero or empty for missing; x == a or b is a classic bug.
Conditional statement
A statement that runs code only when a condition holds, such as if.
Block
A group of statements indented under a line ending in a colon.
elif
Short for else if; an extra condition tested when earlier ones were false.
Guard clause
A check at the start of a function that rejects invalid input or returns early.
Boundary value testing
Testing values just below, on and just above each decision boundary.
Decision table
A table listing combinations of conditions and the required outcome for each.
Nested condition
A conditional statement inside the block of another.
Conditional expression
An expression of the form a if condition else b.
match statement
A statement that selects a case by comparing a value against patterns.
Truthiness
Python's rules for treating non-Boolean values as true or false in conditions.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write an if-elif-else chain that prints "cold" below 5, "mild" from 5 to below 20, and "warm" at 20 or above.

    Show answer

    if t < 5: print("cold") / elif t < 20: print("mild") / else: print("warm")

  2. Which values would you test for the chain in the previous question?

    Show answer

    4.9, 5, 5.1, 19.9, 20 and 20.1, plus an extreme such as -40.

  3. What does letter_grade(80) return, and which branch decides it?

    Show answer

    "A", from the first branch, mark >= 80.

  4. Rewrite: if x > 0: if x < 10: print("single digit") using one condition.

    Show answer

    if 0 < x < 10: print("single digit")

  5. Why is if color == "red" or "blue": always true?

    Show answer

    It means (color == "red") or "blue", and the non-empty string "blue" is truthy.

  6. Write a conditional expression that sets sign to "negative", or "non-negative".

    Show answer

    sign = "negative" if x < 0 else "non-negative"

  7. A parking lot charges nothing for the first 15 minutes, 3 dollars per started hour after that, to a maximum of 20 dollars. Write the decision logic in words.

    Show answer

    If minutes <= 15, charge 0; otherwise charge 3 times the number of started hours, computed as (minutes + 59) // 60, capped at 20.

  8. Why validate input at the start of a function?

    Show answer

    To reject impossible values before they produce misleading results, and to keep the main logic simple.

  9. What does case _ do in a match statement?

    Show answer

    It matches anything, like a final else.

  10. A thermostat function uses if reading: to detect a missing sensor value. When does this go wrong?

    Show answer

    When the reading is exactly 0, which is valid but treated as missing.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz