Decisions are where a program stops being a list of steps and starts having behaviour, and the difference between a readable conditional and a tangled one is mostly a question of ordering. Python gives you if, elif and else with very little ceremony, and since 3.10 it gives you match as well. This lesson covers the syntax, the truthiness rules that decide what counts as true, and the habits that keep a chain of branches from growing into a thicket.
How do you write an if statement in Python?
Three things to remember. A condition ends with a colon, the body is defined by indentation, and there are no braces. The elif and else parts are optional, and exactly one branch in a chain ever runs: the first one whose condition is true.
def grade(score):
if score >= 90:
return 'A'
elif score >= 80:
return 'B'
elif score >= 70:
return 'C'
else:
return 'F'
for value in (95, 83, 71, 60):
print(f'{value:>3} -> {grade(value)}')
Two properties of that chain matter. It stops at the first match, so the order of the conditions is the logic. And each branch ends with a return, so the else is not strictly required, though it is still the clearest way to express "and nothing else matched".
Because indentation carries the meaning, a stray space changes the program. The rule of thumb that keeps editors honest is to use four spaces per level, never tabs, and to let your editor handle it automatically.
What counts as true?
Python does not insist on a boolean in a condition. Any value can be tested, and it is converted to a boolean behind the scenes. The rule is simple: empty things are false, everything else is true. Zero, the empty string, an empty container and None are all falsy; everything else is truthy.
values = [0, 1, -1, 0.0, 0.1, '', '0', 'False', [], [0], {}, None, True, False]
for value in values:
print(f'{value!r:<10} bool -> {bool(value)}')
Look closely at two of those. The non-empty string '0' is truthy, and so is the string 'False', because the container of characters is not empty. A list holding a zero is truthy too, because the list is not empty. Only the container itself being empty makes it false.
That is why if value == True: is a mistake. It works by accident for real booleans, but it also quietly accepts 1 and rejects any truthy object that is not literally True. Write if value: instead, or if value is True: when you truly need identity with the boolean object.
How do you write conditions that read like English?
Put the most specific case first and the general case last. A chain that checks score >= 90 before score >= 80 reads like the rules of a game; a chain that checks score >= 80 first makes the ninety branch unreachable, and the bug is invisible until someone reports that nobody ever gets an A.
The second habit is to test for absence with is None rather than == None. Comparing for equality asks whether two objects are equivalent, which is a question that a class is free to answer in surprising ways. Identity asks whether both names refer to the same object, which is exactly what you mean when you are checking for a missing value.
value = None
print('value is None ->', value is None)
print('value == None ->', value == None)
count = 1
print('count == True ->', count == True)
print('bool(count) ->', bool(count))
flag = True
print('flag is True ->', flag is True)
print('flag is not None->', flag is not None)
The third habit is to keep the body of a branch short by handling the special case first and returning early. Instead of wrapping the main path in an else block four levels deep, you deal with the exception, return, and let the rest of the function run at the top level of the indentation. Flat code is easier to read, easier to test and easier to delete from.
When should you use match instead of if and elif?
Python 3.10 added the match statement, which dispatches on the shape of a value rather than on a list of separate conditions. It is a genuine improvement when you are matching several kinds of structured input, and it is not an improvement when you are simply checking a range of numbers.
def describe(command):
match command:
case {'action': 'quit'}:
return 'shutting down'
case {'action': 'move', 'x': x, 'y': y}:
return f'moving to {x}, {y}'
case {'action': 'move', 'x': x}:
return f'moving to {x}'
case ('go', direction):
return f'going {direction}'
case str() as text:
return f'unknown text command: {text}'
case _:
return 'unknown command'
print(describe({'action': 'quit'}))
print(describe({'action': 'move', 'x': 3, 'y': 4}))
print(describe({'action': 'move', 'x': 3}))
print(describe(('go', 'north')))
print(describe('fly'))
print(describe(123))
Each case tries to match the shape of the value, and the first one that matches wins. The dictionary patterns check for specific keys and pull their values out at the same time, which removes the dictionary lookups you would otherwise write. The final case _ is the catch-all, and it is required if you want a total function rather than one that returns None for unmatched input.
Two cautions. The match statement is not a general switch, and a chain of if statements with simple comparisons is usually clearer than a match with one arm. And you cannot fall through from one case to the next; if several cases should run, they need to be combined into one pattern or written as separate branches.
Why do float comparisons bite?
Because a float stores an approximation, and the approximation that represents 0.1 plus the approximation that represents 0.2 is not the approximation that represents 0.3. Comparing them with == therefore fails in a way that looks like a maths error but is really a representation error.
import math
left, right = 0.1 + 0.2, 0.3
print('0.1 + 0.2 == 0.3 ->', left == right)
print('isclose ->', math.isclose(left, right))
print('difference ->', abs(left - right))
print('within tolerance ->', abs(left - right) < 1e-9)
Use math.isclose() when comparing computed floats, and use an explicit tolerance when you need to write the test yourself. The rule applies to any derived value, not just sums, and it is one of the differences between code that works on your machine and code that works on your customer's.
Your turn: rewrite a decision tree
- Write a function
shipping_cost(weight_kg, is_express)that returns a cost: free over 20 kg, discounted over 10 kg, express adds a surcharge, and anything invalid returnsNone. - Then restructure it with guard clauses: each special case returns immediately, and the normal path stays at the left margin. Print both versions and confirm they agree for a table of test values.
- Add a check for a negative weight that returns
Nonebefore any cost is calculated, and make sure the check uses<rather than==. - Rewrite the whole thing with
match, using a tuple pattern for the arguments. Keep both versions in the file so you can compare them side by side. - Finally, write one condition using
is Noneand another using== Noneagainst a value that might beNone, then explain in a comment why the first is the safer habit.
Decision checklist
- Conditions end with a colon and the body is defined by indentation, never braces.
- Only one branch of a chain runs, so order the branches from most specific to most general.
- Test emptiness, not equality with
True, and check for missing values withis None. - Keep branches short and handle special cases with early returns so the main path stays flat.
- Reach for
matchwhen you are dispatching on the shape of structured values, not for simple comparisons. - Compare computed floats with
math.isclose(), never with==.