PythonMastery
beginner 14 min read · lesson 10 of 19 in Python Fundamentals

Control Flow: Conditionals

1 · The lesson

read

A 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

python
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:

python
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:

python
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:

python
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:

python
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.

python
# 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:

python
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:

python
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:

python
# 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:

python
# 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"
python
# 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

python
# 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:

python
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

python
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)

python
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:

python
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 with or: 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
python
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. Use if items:, not if len(items) > 0:.
  • Comparison chaining: 0 < x < 100 works and reads like maths.
  • Ternary: a if cond else b for short value picks.
  • match (3.10+): a cleaner alternative to a long elif chain 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), not x == 1 or 2.

Next: Loops, for repeating code until the work is done.

Practice this

on practicepython.in

Short 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.