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

Control Flow: Conditionals

1 · The lesson

read

Code 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

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

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

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 force you to write 0 < x and x < 100. Python lets you chain:

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

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

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

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

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

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

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

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

python
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

python
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

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

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 — 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 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'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
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 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, False 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+): clean alternative to long elif chains 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), not x == 1 or 2.

Next: Loops — making decisions over and over until the work is done.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.