Control Flow: Conditionals
1 · The lesson
readCode that always runs the same lines is a calculator with extra steps. Conditionals are how a program decides — branching one way for valid input, another for invalid, a third for the edge case nobody warned you about. Get this right and the rest of programming is just plumbing.
1. The Basic Shape
temperature = 28 if temperature > 30: print("Hot.") elif temperature > 20: print("Pleasant.") elif temperature > 10: print("Cool.") else: print("Cold.")
Three rules, and that's the whole syntax:
1. The if / elif / else line ends with a colon :.
2. The body is indented — 4 spaces, consistently. The indentation is the grammar (see intro).
3. Python checks each branch top-to-bottom and runs the first one that matches. The else catches everything left.
You can have any number of elif branches. The else is optional.
2. Truthiness — What Counts as False
Python evaluates the condition by asking "is this truthy?" — not strictly "is this True?". Six values are falsy:
bool(0) # False bool("") # False — empty string bool([]) # False — empty list bool({}) # False — empty dict (also empty set) bool(None) # False bool(False) # False
Everything else is truthy. Which means this works without writing len(items) > 0:
items = [] if items: print(f"Got {len(items)} items.") else: print("Nothing to process.") # this branch runs
This is idiomatic Python. Don't write if len(items) > 0: when if items: says the same thing more clearly.
3. Comparison Chaining
Most languages force you to write 0 < x and x < 100. Python lets you chain:
x = 47 if 0 < x < 100: print("Two-digit positive.") # Reads exactly like maths. Works with any combination: age = 25 if 18 <= age < 65: print("Working age.")
Each comparison is evaluated once, left-to-right. 0 < x < 100 is equivalent to 0 < x and x < 100 — but x is only computed once, which matters when it's a function call.
4. The Ternary Expression
When you just need to pick between two values, the full if/else block is overkill:
age = 17 label = "adult" if age >= 18 else "minor" print(label) # minor
Shape: value_if_true if condition else value_if_false. Reads English-first — the value comes before the condition. Use it for short assignments and return values. If the expressions get long, switch back to a multi-line if — readability wins.
# Fine status = "ok" if response.code == 200 else "fail" # Don't do this — too dense to scan result = (compute_expensive(x, y) if cache_hit and not stale and user.is_authenticated else fallback_value)
setup added so this can run · defines fallback_value, cache_hit, compute_expensive, x, y, response, stale, user
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) fallback_value = _AutoMock('fallback_value') cache_hit = _AutoMock('cache_hit') def compute_expensive(*_a, **_kw): print('-> compute_expensive() called') return _AutoMock('compute_expensive()') x = _AutoMock('x') y = _AutoMock('y') response = _AutoMock('response') stale = 1 user = _AutoMock('user')
5. The match Statement (Python 3.10+)
match is the modern alternative to a long elif chain when you're branching on the shape or value of one thing:
def http_status(code): match code: case 200 | 201 | 204: return "Success" case 301 | 302: return "Redirect" case 400 | 401 | 403 | 404: return "Client error" case 500 | 502 | 503: return "Server error" case _: return "Unknown" print(http_status(404)) # Client error
The | means "or". The _ is the wildcard — it matches anything and acts as the default. match can also destructure tuples, lists, and objects — that's a deeper topic for later. For now, treat it as a tidy switch.
6. The Walrus Operator :=
Sometimes you want to check a value and use it. The walrus assigns inside an expression:
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] if (n := len(data)) > 10: print(f"Large dataset — {n} items. Sampling first 10.") data = data[:10]
setup added so this can run · defines n
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) n = _AutoMock('n')
Without the walrus, you'd call len(data) twice or assign on a separate line first. It earns its keep in loops too:
# Read lines until empty input — assign and test in one step while (line := input("> ")) != "quit": print(f"You said: {line}")
setup added so this can run · defines line
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) line = _AutoMock('line')
Don't reach for it constantly. When it shortens code and keeps it readable, use it. When it doesn't, don't.
7. Guard Clauses — Flatten the Nesting
Nested ifs are a code smell. Compare:
# Nested — hard to follow, "arrow code" def process(user): if user is not None: if user.is_active: if user.has_permission("write"): return user.write_data() else: return "no permission" else: return "inactive" else: return "no user"
# Guard clauses — return early, keep the happy path flat def process(user): if user is None: return "no user" if not user.is_active: return "inactive" if not user.has_permission("write"): return "no permission" return user.write_data()
Same logic, half the cognitive load. The happy path stays at one indentation level. Whenever you find yourself three ifs deep, refactor.
Common Mistakes
1. = instead of == in a condition
# if x = 5: # SyntaxError in Python — good! if x == 5: # equality test — what you meant print("five")
setup added so this can run · defines x
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) x = _AutoMock('x')
Python catches this at parse time. Other languages don't — C, JavaScript, and Go will happily assign and use the result as a condition. Build the habit anyway.
2. if x == True: instead of if x:
is_admin = True # Verbose and wrong-shaped if is_admin == True: grant_access() # Correct — truthiness handles it if is_admin: grant_access()
setup added so this can run · defines grant_access
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def grant_access(*_a, **_kw): print('-> grant_access() called') return _AutoMock('grant_access()')
If x is already a bool, x == True is redundant. If x is something else (a list, an int), x == True is wrong — [1] == True is False, but if [1]: is truthy.
3. Comparing to None with == instead of is
user = None # Works but not idiomatic if user == None: ... # Correct if user is None: ...
None is a singleton — there is exactly one None object in memory. is checks identity (same object), which is faster and bypasses any __eq__ weirdness a custom class might define. PEP 8 makes is None / is not None the official convention.
4. if x == 1 or 2: — the classic trap
x = 7 # Bug: this is always True. Python reads it as: (x == 1) or 2 — and 2 is truthy. if x == 1 or 2: print("matched") # prints! # Correct options: if x == 1 or x == 2: ... if x in (1, 2): # cleaner — one comparison ...
The in form is the right one once you have three or more values to check.
5. Deeply nested conditions
Already covered in Section 7. If your code marches diagonally off the right edge of the screen, you need guard clauses or a helper function.
🎯 Your Turn — Classify a Triangle
Write classify_triangle(a, b, c) that takes three side lengths and returns one of four strings:
"equilateral"— all three sides equal"isosceles"— exactly two sides equal"scalene"— all sides different"invalid"— the sides don't form a triangle
Triangle inequality: for any valid triangle, the sum of any two sides must be strictly greater than the third. Also, sides must be positive.
Skeleton:
def classify_triangle(a, b, c): # TODO 1: reject invalid triangles first (guard clause) # TODO 2: check equilateral # TODO 3: check isosceles # TODO 4: otherwise scalene ... # Quick checks print(classify_triangle(3, 3, 3)) # equilateral print(classify_triangle(5, 5, 8)) # isosceles print(classify_triangle(3, 4, 5)) # scalene print(classify_triangle(1, 2, 3)) # invalid — 1 + 2 == 3, not strictly greater print(classify_triangle(-1, 2, 2)) # invalid
Hint 1 — The invalid check
Two conditions for invalid: any side ≤ 0, OR the triangle inequality fails for any pair. Combine withor:
a <= 0 or b <= 0 or c <= 0 or a + b <= c or a + c <= b or b + c <= a. Return "invalid" early — that's a guard clause.
Hint 2 — Counting equal sides
After the guard, ask: are all three equal? Then isosceles is "any two equal" —a == b or b == c or a == c. Anything that falls through is scalene.
Show full solution
def classify_triangle(a, b, c): # Guard clause — reject impossible triangles first if a <= 0 or b <= 0 or c <= 0: return "invalid" if a + b <= c or a + c <= b or b + c <= a: return "invalid" if a == b == c: # comparison chaining shines here return "equilateral" if a == b or b == c or a == c: return "isosceles" return "scalene" print(classify_triangle(3, 3, 3)) # equilateral print(classify_triangle(5, 5, 8)) # isosceles print(classify_triangle(3, 4, 5)) # scalene print(classify_triangle(1, 2, 3)) # invalid print(classify_triangle(-1, 2, 2)) # invalid
Notice the structure: two guard clauses up top, then three positive checks in decreasing specificity (most specific first), then the catch-all return. No else needed — every branch returns, so the next line only runs if all previous checks failed. This is the guard-clause pattern in its natural habitat.
What You Learned
if/elif/else— colon, indent, top-to-bottom evaluation.- Truthiness:
0,"",[],{},None,Falseare falsy. Everything else is truthy. Useif items:, notif len(items) > 0:. - Comparison chaining:
0 < x < 100works and reads like maths. - Ternary:
a if cond else bfor short value picks. match(3.10+): clean alternative to longelifchains on one value.- Walrus
:=: assign-and-test in one expression, used sparingly. - Guard clauses flatten nested logic — return early on edge cases.
is None, not== None.x in (1, 2), notx == 1 or 2.
Next: Loops — making decisions over and over until the work is done.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.