if, elif, else — and Why the Order Matters
A conditional runs a block of code only when something is true. The shape is fixed: the keyword, a condition, a colon, then an indented block underneath. elif is short for "else if" and you may have as many as you need; else is optional and runs when nothing above it matched.
The rule that does the real work is that only the first matching branch runs. Python tests the conditions top to bottom, executes the first one that is true, and skips every remaining branch — including ones that would also have been true. That is what makes an if/elif chain different from a stack of separate if statements, where every true condition fires.
Because the first match wins, the order of your conditions is part of the logic, not a matter of taste. In the grade example below, putting score >= 60 first makes every other branch unreachable: a score of 95 is also greater than 60, so it matches the first test and is given a D. Nothing crashes, no warning appears, and the program is simply wrong. When the conditions overlap, put the narrowest one first.
Note also that assigning inside each branch, then printing once at the end, is usually better than printing inside every branch. It keeps the decision and the output separate, so changing the wording later means editing one line instead of five.
age = 20
if age >= 18:
print("You may vote.")
temperature = 35
if temperature > 30:
print("Hot")
else:
print("Pleasant")
# Correct: narrowest condition first
score = 95
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
elif score >= 60:
grade = "D"
else:
grade = "F"
print(f"Grade: {grade}") # Grade: A
# Same conditions, wrong order — every score above 60 becomes a D
if score >= 60:
grade = "D"
elif score >= 90: # unreachable: 90 and above already matched above
grade = "A"
print(f"Grade: {grade}") # Grade: D <- silently wrong
# elif chain vs separate ifs
n = 15
if n % 3 == 0:
print("divisible by 3")
if n % 5 == 0:
print("divisible by 5") # BOTH print — separate statements iftests the first condition; the block under it is indentedelifadds another condition, tested only if everything above failedelsecatches everything left over; it takes no condition- Only the first true branch runs — the rest are skipped entirely
- Two separate
ifstatements are not the same asif/elif: both can fire
- Forgetting the colon gives
SyntaxError: expected ':'. Writing the colon but not indenting the next line givesIndentationError: expected an indented block. Both are quick to fix once you recognise which is which.
Truthiness: What Python Counts as False
A condition does not have to be a comparison. Python will accept any value in an if and decide for itself whether it counts as true. The list of values that count as false is short and worth memorising, because everything not on it is true.
Empty things are false and non-empty things are true: the empty string, the empty list, the empty tuple, the empty dictionary, the empty set. Zero of any numeric type is false. None is false. False is false. That is the whole list. This is why if items: is the idiomatic way to ask "does this list have anything in it" — it is shorter than if len(items) > 0: and it is what experienced Python programmers expect to read.
The trap is the value that is legitimately zero or legitimately empty. If a student genuinely scored 0, then if marks: treats their result as missing data. If someone's chosen nickname is the empty string, if nickname: treats it as not provided. When zero and "not provided" are different things in your problem, you must say which you mean: if marks is not None: asks about presence, if marks: asks about non-zero-ness, and conflating them produces bugs that only appear for one unlucky user.
One more piece of the same puzzle: None is a real object, not an absence. It is Python's way of saying "no value here", and it is the standard placeholder for a variable that will be filled in later. Test for it with is None and is not None rather than ==, because there is exactly one None object in a running program and identity is precisely the right question.
# Everything on this list is false; everything else is true
for value in [0, 0.0, "", [], {}, set(), None, False]:
print(repr(value), "->", bool(value))
# So the idiomatic emptiness test is just the value itself
items = []
if items:
print("has items")
else:
print("empty") # empty
# The zero trap
marks = 0 # the student scored zero
if marks:
print(f"Marks: {marks}")
else:
print("No marks recorded") # WRONG: 0 is a real score
if marks is not None:
print(f"Marks: {marks}") # Marks: 0 correct
# None means "nothing here yet"
result = None
print(result is None) # True
print(result == None) # also True, but is None is the convention - False values:
False,None,0,0.0,"",[],(),{},set() - Everything else is true, including
"0","False"and[0] if items:— the idiomatic "is this container non-empty" testif x is not None:— the presence test; use it when 0 or "" are valid valuesbool(x)shows you what Python will decide, if you are unsure
"False"is a non-empty string, so it is true. Text read from a file or a form is never a boolean until you convert it, andbool("False")being True has caught out a great many people.
Writing Conditions That Read Well
A condition is read far more often than it is written, and a few habits make the difference between a line you understand at a glance and one you have to decode.
Chain comparisons the way mathematics does. 0 <= marks <= 100 is legal Python and says exactly what it means; marks >= 0 and marks <= 100 says the same thing with more moving parts. Test membership with in rather than a row of ors: if grade in ("A", "B"): beats if grade == "A" or grade == "B":, is harder to get wrong, and grows to ten options without changing shape.
Never compare a boolean to True. if is_active == True: is noise — if is_active: says the same thing — and it is subtly fragile, because 1 == True is also True in Python and so the comparison can succeed for a value that is not a boolean at all. The negative form is if not is_active:.
Finally, give conditions names. When a test needs three clauses and a comment to explain it, assign it to a well-named variable first: is_eligible = attendance >= 75 and fees_paid and not is_barred, then if is_eligible:. The condition now documents itself, and you can print the variable while debugging instead of re-evaluating the expression in your head.
marks = 78
# Chained comparison — reads like maths
if 0 <= marks <= 100:
print("valid")
# Membership beats a chain of ors
grade = "B"
if grade in ("A", "B"):
print("distinction")
vowels = "aeiou"
if "y" not in vowels:
print("not a vowel")
# Do not compare booleans to True
is_active = True
if is_active: # good
print("active")
if is_active == True: # noise, and 1 == True would also pass
print("active")
# Name a complicated condition
attendance, fees_paid, is_barred = 82, True, False
is_eligible = attendance >= 75 and fees_paid and not is_barred
if is_eligible:
print("May sit the exam") a <= x <= b— chained comparison; the middle expression is evaluated oncex in ("a", "b", "c")— one test instead of severalorsif flag:andif not flag:— never== Trueor== False- Assign a long condition to a named variable and test the name
andandorshort-circuit, soif user is not None and user["active"]is safe
Flattening Nested Conditions
You can put an if inside another if, and sometimes you must. But nesting costs the reader something real: at three levels deep, understanding any single line means holding three conditions in your head at once, and the else branches drift so far from their if that you have to count indentation to pair them up.
Two techniques flatten most of it. The first is combining conditions with and when the inner test has no separate else. Two nested checks that only ever lead to one outcome are just one check with two clauses.
The second is handling the failure cases first and getting them out of the way. Instead of wrapping the real work in a successful branch, test each thing that could be wrong, deal with it, and let the main path sit unindented at the bottom. Once you meet functions in Lesson 13 this becomes the guard clause pattern — check the bad cases and return early — and it is one of the most reliable readability improvements you can make to existing code.
A practical ceiling: if you are past three levels of indentation inside a single decision, the code is telling you something. Either a condition can be combined, a failure case can be dealt with early, or a chunk of the logic wants to be its own function.
age, has_license, is_insured = 25, True, True
# Nested — hard to follow once the branches grow
if age >= 18:
if has_license:
if is_insured:
print("May drive")
else:
print("Not insured")
else:
print("No licence")
else:
print("Too young")
# Same logic, failures handled first, main path unindented
if age < 18:
print("Too young")
elif not has_license:
print("No licence")
elif not is_insured:
print("Not insured")
else:
print("May drive")
# When the inner check has no else of its own, just combine
if age >= 18 and has_license:
print("May drive") - Combining conditions with
andis also safe when the second test would fail on its own.andstops at the first false clause, soif data and data[0] == "x":never indexes an empty list.
One-Line Decisions and match/case
Python has a conditional expression, often called the ternary: value_if_true if condition else value_if_false. Unlike an if statement it produces a value, so it can go on the right-hand side of an assignment, inside an f-string, or as an argument to a function. Its natural use is choosing between two values, and there the one-line form genuinely reads better than four lines of block.
It stops reading better as soon as you nest it. "positive" if n > 0 else "zero" if n == 0 else "negative" is legal and does work, but three-way and four-way versions are the reason many teams ban the construct outright. The honest rule is one condition per expression; beyond that, use an if/elif chain and accept the extra lines.
From Python 3.10 there is also match, which compares one value against a series of patterns. For simple values it is a tidier way to write a long elif chain, and the | symbol lets one branch cover several alternatives. case _ is the catch-all, equivalent to a final else. It can do considerably more than this — unpacking lists and objects — but for a first course, treat it as a readable multi-way switch and check that your Python is 3.10 or newer before relying on it.
age = 25
# Conditional expression: produces a value
status = "adult" if age >= 18 else "minor"
print(status) # adult
# Works anywhere a value is expected
items = []
print(f"{len(items)} item{'' if len(items) == 1 else 's'}")
# Legal, but this is the readability limit
n = -5
label = "positive" if n > 0 else "zero" if n == 0 else "negative"
print(label) # negative
# match/case — Python 3.10 and later
command = "ls"
match command:
case "add":
print("adding a task")
case "list" | "ls": # one branch, several alternatives
print("listing tasks")
case "quit" | "exit":
print("goodbye")
case _: # the catch-all
print(f"unknown command: {command}") - Python has no
switchstatement before 3.10 and never had C's fall-through behaviour. If you need to support older versions, a dictionary mapping each value to what should happen is the traditional alternative to a longelifchain.
Three Conditional Bugs Worth Knowing
The first bites everyone eventually: 0.1 + 0.2 == 0.3 is False. Computers store decimals in binary, and a third of the everyday decimal fractions have no exact binary form, exactly as one-third has no exact decimal form. The sum comes out as 0.30000000000000004, and == tells the truth about it. Never test floats for equality. Compare with a tolerance — math.isclose(a, b) does it properly — and for money use whole paise as integers, or the decimal module.
The second is is where == was meant. == compares values; is compares object identity. CPython caches small integers from -5 to 256, so is gives the right answer for small numbers and then silently starts giving the wrong one above 256. The bug is invisible in testing with small values and appears in production with large ones. Use == for values; use is only with None, True and False.
The third is indentation that changes the meaning without causing an error. Python decides which block a line belongs to purely from how far it is indented, so shifting a line by four spaces moves it from inside a branch to after the whole conditional — and it will run every time instead of sometimes. There is no error message for this because both versions are valid Python. Consistent four-space indentation and a quick reread of what lines up with what is the entire defence.
A related note: writing = instead of == inside a condition is a SyntaxError in Python rather than a silent bug, which is a genuine kindness the language does you. Assignment inside a condition does exist, but you must ask for it explicitly with the walrus operator :=, available from Python 3.8.
import math
# 1) Floats are not exact
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
print(math.isclose(0.1 + 0.2, 0.3)) # True <- compare with a tolerance
# 2) is compares identity, not value
a = 5
b = int("5")
print(a == b, a is b) # True True — small ints are cached
x = 1000
y = int("1000")
print(x == y, x is y) # True False — outside the cache
# 3) Indentation silently changes what runs
marks = 40
if marks >= 50:
print("pass")
print("certificate issued") # inside the if
if marks >= 50:
print("pass")
print("certificate issued") # OUTSIDE — runs every time
# = in a condition is a SyntaxError, not a silent bug
# if marks = 50: # SyntaxError
data = [1, 2, 3, 4]
if (n := len(data)) > 3: # deliberate assignment, Python 3.8+
print(f"{n} items") math.isclose(a, b)uses a relative tolerance, which is what you want for measurements. For currency, avoid floats altogether: store rupees as an integer number of paise, or usedecimal.Decimal.
