Control Flow: Conditionals
1 · The lesson
readA program that always runs the same lines can't react to its input. Conditionals let a program decide what to do: one thing for valid input, another for invalid input, and a third for an unusual case (an edge case).
1. The Basic Shape
temperature = 28 if temperature > 30: print("Hot.") elif temperature > 20: print("Pleasant.") elif temperature > 10: print("Cool.") else: print("Cold.")
The whole syntax follows three rules:
1. The if / elif / else line ends with a colon :.
2. The body is indented by 4 spaces, the same in every line. In Python, the indentation is part of the syntax (see intro).
3. Python checks each branch from top to bottom and runs the first one that matches. The else branch runs if no other branch matched.
You can have any number of elif branches. The else is optional.
2. Truthiness: What Counts as False
Python checks whether the condition is truthy, not whether it is exactly True. These are the common falsy values:
bool(0) # False bool("") # False (empty string) bool([]) # False (empty list) bool({}) # False (empty dict, because {} is a dict, not a set) bool(None) # False bool(False) # False
So is any other zero or empty container, like 0.0, () or set(). Everything else is truthy. This means the code below 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 make you write 0 < x and x < 100. Python lets you chain the comparisons:
x = 47 if 0 < x < 100: print("Two-digit positive.") # This reads exactly like maths, and works with any combination: age = 25 if 18 <= age < 65: print("Working age.")
The comparisons are evaluated from left to right. 0 < x < 100 is equivalent to 0 < x and x < 100, except that x is only computed once. This matters when x is a function call.
4. The Ternary Expression
When you only need to choose between two values, you don't need a full if/else block:
age = 17 label = "adult" if age >= 18 else "minor" print(label) # minor
Shape: value_if_true if condition else value_if_false. It reads like an English sentence: the value comes before the condition. Use it for short assignments and return values. If the expressions get long, use a normal multi-line if instead, because it is easier to read.
# Fine status = "ok" if response.code == 200 else "fail" # Don't do this: it is too dense to read quickly 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, so it works as the default case. match can also take apart tuples, lists and objects, but that is a topic for a later lesson. For now, use it as a cleaner way to compare one value against many options.
6. The Walrus Operator :=
Sometimes you want to check a value and then use it. The walrus operator assigns a value 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 with {n} items. Sampling the 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 operator, you would call len(data) twice, or assign it on a separate line first. It is also useful in loops:
# Read lines until the user types quit. The loop assigns and tests 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 use it everywhere. Use it when it makes the code shorter and still easy to read.
7. Guard Clauses: Flatten the Nesting
Deeply nested ifs are hard to read. Compare these two versions:
# Nested: hard to follow (sometimes called "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, and keep the normal 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()
The logic is the same, but the second version is much easier to follow. The normal case (the "happy path") stays at one indentation level. When you find yourself three ifs deep, rewrite the code with guard clauses.
Common Mistakes
1. = instead of == in a condition
# if x = 5: # SyntaxError in Python, which is good! if x == 5: # equality test, which is what you meant print("five")
setup added so this can run · re-runs 1 earlier block on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'x = 47\n\nif 0 < x < 100:\n print("Two-digit positive.")\n\n# This reads exactly like maths, and works with any combination:\nage = 25\nif 18 <= age < 65:\n print("Working age.")', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
Python reports this error before the program runs. Some other languages, such as C and JavaScript, allow it: they assign the value and use it as the condition. Learn to type == anyway.
2. if x == True: instead of if x:
is_admin = True # Too long, and it can give the wrong answer if is_admin == True: grant_access() # Correct: the if checks truthiness by itself 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 gives the wrong answer: [1] == True is False, but [1] is truthy, so if [1]: runs its body.
3. Comparing to None with == instead of is
user = None # Works, but is not the usual Python style if user == None: ... # Correct if user is None: ...
None is a singleton: there is exactly one None object in memory. is checks identity (whether two names point to the same object). It is faster, and it ignores any unusual __eq__ method that a custom class might define. PEP 8 makes is None / is not None the official convention.
4. if x == 1 or 2: (a very common bug)
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, with one comparison ...
The in form is the right one once you have three or more values to check.
5. Deeply nested conditions
Section 7 covers this. If your code is indented so deeply that it moves towards the right edge of the screen, use 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, because 1 + 2 is not greater than 3 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 is 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. If neither check matches, the triangle 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 is very useful 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 is needed: every branch returns, so a line only runs if all the checks above it failed. This is the guard-clause pattern in a typical function.
What You Learned
if/elif/else: a colon, an indented body, and checks from top to bottom.- Truthiness:
0,"",[],{},None,False(and any other zero or empty container) are 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+): a cleaner alternative to a longelifchain that tests one value.- Walrus
:=: assign and test in one expression. Use it only where it helps. - Guard clauses remove nesting: return early for the edge cases.
is None, not== None.x in (1, 2), notx == 1 or 2.
Next: Loops, for repeating code until the work is done.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.