Skip to main content

Python if/elif/else - What Nobody Teaches You

Reading time: ~16 minutes | Level: Foundation → Engineering

What does this print?

x = 0
y = ""
z = []

if x or y or z:
print("truthy")
else:
print("falsy")

Now what about this?

score = 85

if score >= 70:
grade = "C"
elif score >= 80:
grade = "B"
elif score >= 90:
grade = "A"

print(grade)

If you said "B" - you have a bug in your mental model.

The answer is "C". And understanding exactly why determines whether you write reliable conditional logic or fragile branching systems.

What You Will Learn

  • Why Python evaluates if conditions at the object level, not just boolean
  • How to predict which elif branch wins when conditions overlap
  • What truthy and falsy mean for every built-in Python type
  • How to write clean ternary expressions without sacrificing readability
  • The exact cognitive cost of each nesting level and why it matters
  • How to convert deeply nested conditions into flat guard clause structures
  • The match/case alternative for structural conditions (Python 3.10+)
  • Top 5 production bugs caused by incorrect conditional logic
  • How this connects to data validation in scikit-learn and PyTorch pipelines

Prerequisites

  • Python variable binding (Module 2)
  • Python operators - comparison and logical (Module 2)
  • Running Python code in a terminal or REPL

The Mental Model: Conditions as Gates

Before writing any conditional code, visualize it as a gate system:

Key insight: Python stops at the FIRST True condition. All remaining elif/else blocks are skipped.

This is why condition order matters as much as condition content.

Part 1 - The if Statement: What Actually Happens

When Python evaluates:

if condition:
body

It does not check "is this True?"

It checks: "is this truthy?"

Those are different things.

# All of these execute the if-block
if 1: print("1 is truthy") # integers != 0
if "hello": print("non-empty str") # non-empty strings
if [1, 2]: print("non-empty list") # non-empty lists
if {"a": 1}: print("non-empty dict") # non-empty dicts
if 0.001: print("non-zero float") # non-zero floats

# All of these skip the if-block
if 0: print("never") # zero
if "": print("never") # empty string
if []: print("never") # empty list
if {}: print("never") # empty dict
if None: print("never") # None
if 0.0: print("never") # zero float
if False: print("never") # explicit False

Python calls bool() on the condition. The bool() function calls __bool__() on the object. If __bool__ is not defined, it falls back to __len__(). This is why empty containers are falsy - their length is zero.

:::info How Python Checks Truthiness

# Python evaluates `if x:` by calling:
bool(x)
# which calls:
x.__bool__() # if defined
# or falls back to:
x.__len__() != 0 # if __bool__ not defined

:::

Part 2 - The Truthy/Falsy Table for Every Type

TypeFalsy valuesTruthy values
int0any non-zero
float0.0, -0.0any non-zero
complex0+0jany non-zero
str""any non-empty string
bytesb""any non-empty bytes
list[][anything]
tuple()(anything,)
dict{}{key: value}
setset(){anything}
NoneTypeNone(never truthy)
boolFalseTrue
Custom class__bool__ returns False or __len__ == 0__bool__ returns True or __len__ > 0
# Demonstrating __bool__ override
class AlwaysFalsy:
def __bool__(self):
return False

class AlwaysTruthy:
def __bool__(self):
return True

obj1 = AlwaysFalsy()
obj2 = AlwaysTruthy()

print(bool(obj1)) # False
print(bool(obj2)) # True

if obj1:
print("never")

if obj2:
print("always prints")

:::tip When to Use Implicit Truthiness Use if x: instead of if x is not None: when you want to treat all falsy values (None, empty, zero) as the same case. Use if x is not None: when 0, "", or [] are valid values that should not be skipped. :::

Part 3 - The elif Chain: Evaluation Order is Everything

Back to the opening puzzle:

score = 85

if score >= 70:
grade = "C"
elif score >= 80:
grade = "B"
elif score >= 90:
grade = "A"

print(grade) # "C" - not "B"!

Why does score = 85 produce "C"?

Because 85 >= 70 is True. Python executes the if block and stops. The elif score >= 80 line is never evaluated.

The correct order:

score = 85

if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"

print(grade) # "B" - correct
❌ Wrong order✅ Correct order
Condition 1if x >= 70: "C"if x >= 90: "A"
Condition 2elif x >= 80: "B"elif x >= 80: "B"
Condition 3elif x >= 90: "A"elif x >= 70: "C"
Fallback(missing)else: "F"
Result for x=85"C" - wrong, stops at first match"B" - correct

Rule: Most specific (highest threshold) conditions must come first. General conditions go last.

:::danger Common Bug Ordering elif conditions from least specific to most specific is one of the most common logic bugs in production code. It passes most unit tests (which usually test the easy cases) and fails silently on edge cases. :::

Watch: Python Conditionals Explained

Part 4 - Ternary Expressions: Concise but Not Always Clear

Python's ternary (conditional expression):

# Syntax: value_if_true if condition else value_if_false
result = "adult" if age >= 18 else "minor"

This is equivalent to:

if age >= 18:
result = "adult"
else:
result = "minor"

When Ternary is Clean

# Assigning with a simple condition
label = "positive" if x > 0 else "non-positive"

# Inside a list comprehension
results = ["pass" if s >= 60 else "fail" for s in scores]

# Returning from a function
def classify(n):
return "even" if n % 2 == 0 else "odd"

When Ternary Becomes Unreadable

# DO NOT DO THIS - unreadable nested ternary
result = "A" if score >= 90 else "B" if score >= 80 else "C" if score >= 70 else "F"

# Use if/elif instead
if score >= 90:
result = "A"
elif score >= 80:
result = "B"
elif score >= 70:
result = "C"
else:
result = "F"

:::note Ternary Guideline Use ternary when: the condition is simple, both branches are short values, and there is no nesting. Switch to if/elif/else the moment any of those three constraints is violated. :::

Part 5 - Nested Conditions: The Cognitive Cost

Every nested if adds a level to the mental stack a reader must maintain.

# Level 0: no nesting
process(data)

# Level 1: tracking 1 condition
if valid:
process(data)

# Level 2: tracking 2 conditions simultaneously
if valid:
if active:
process(data)

# Level 3: tracking 3 conditions simultaneously
if valid:
if active:
if has_permission:
process(data)

# Level 4: tracking 4 conditions - this is too much
if valid:
if active:
if has_permission:
if not rate_limited:
process(data)

The deeper you go, the more state a reader must track:

Cognitive Cost of Nesting

Depth 1: 2 possible states (valid=True, valid=False)
Depth 2: 4 possible states (valid x active combinations)
Depth 3: 8 possible states (valid x active x permission)
Depth 4: 16 possible states

Each level DOUBLES the number of paths a reader must reason about.

This is why deep nesting is an engineering problem, not a style preference.

Part 6 - Flattening Nested Conditions

The most powerful tool for eliminating nesting is the guard clause - an early return or exit when a precondition fails.

Before: Nested (4 levels deep)

def process_user_request(user, request):
if user is not None:
if user.is_active:
if user.has_permission(request.resource):
if not user.is_rate_limited():
return execute_request(request)
else:
return "rate limited"
else:
return "no permission"
else:
return "inactive user"
else:
return "user not found"

After: Flat (1 level deep with guard clauses)

def process_user_request(user, request):
if user is None:
return "user not found"

if not user.is_active:
return "inactive user"

if not user.has_permission(request.resource):
return "no permission"

if user.is_rate_limited():
return "rate limited"

return execute_request(request)

The flat version is easier to:

  • Read (top to bottom, one condition at a time)
  • Test (each condition is independent)
  • Extend (add a new guard without restructuring)
  • Debug (each early exit is a clear signal)

:::tip The Guard Clause Rule If you find yourself writing if valid: ... else: return error, invert it: if not valid: return error. This reduces nesting by one level for every guard you add. :::

Part 7 - Combining Conditions vs Nesting

Sometimes nesting can be eliminated by combining conditions with and:

# Nested
if user:
if user.is_active:
process(user)

# Flat with and
if user and user.is_active:
process(user)

But be careful: combining conditions collapses all branches into one:

# Nested - can return different error messages
if user is None:
return "no user"
if not user.is_active:
return "inactive"

# Combined - loses the ability to distinguish
if user is None or not user.is_active:
return "error" # which error?

Use combining when the reason for failure does not matter. Use guard clauses when each failure needs a specific response.

Watch: Guard Clauses and Clean Code

Part 8 - The else Clause: When to Include It

else is not required. Knowing when to omit it is a mark of experience.

Always include else when there is no early exit:

# Two mutually exclusive outcomes - else is appropriate
if score >= 60:
result = "pass"
else:
result = "fail"

Omit else when early returns handle the failure:

def validate(user):
if user is None:
return False # early exit - no else needed
if not user.is_active:
return False # early exit - no else needed
return True # happy path at the end

Omit else when it creates dead code:

# Unnecessary else - the function already returned
def check(x):
if x > 0:
return "positive"
else:
return "non-positive" # else is redundant after return

# Better:
def check(x):
if x > 0:
return "positive"
return "non-positive"

Part 9 - Comparing Multiple Values Efficiently

Python allows chained comparisons that most languages do not:

# Standard approach (2 conditions)
if 0 <= age and age <= 120:
process(age)

# Python chained comparison (cleaner)
if 0 <= age <= 120:
process(age)

# Checking membership
status = "active"
if status == "active" or status == "pending":
notify()

# Better: use `in`
if status in ("active", "pending"):
notify()
# Real production example: HTTP status code routing
def handle_response(status_code):
if status_code in range(200, 300):
return "success"
if status_code in range(300, 400):
return "redirect"
if status_code in range(400, 500):
return "client error"
if status_code in range(500, 600):
return "server error"
return "unknown"

Top 5 Conditional Bugs in Production Code

Bug 1: Wrong elif Order

# Bug: score 85 returns "C" not "B"
def get_grade(score):
if score >= 70:
return "C" # This catches 85 first
elif score >= 80:
return "B" # Unreachable for scores 70-89
elif score >= 90:
return "A" # Unreachable for scores 70-99

# Fix: most specific first
def get_grade(score):
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
else:
return "F"

Bug 2: Truthiness Trap with Valid Empty Values

# Bug: valid empty list treated as missing
def process(items=None):
if not items: # [] and None both falsy!
return "no items"
return sum(items)

process([]) # "no items" - but empty list IS valid input
process(None) # "no items" - this is correct

# Fix: check None explicitly
def process(items=None):
if items is None:
return "no items"
return sum(items)

process([]) # 0 - correct
process(None) # "no items" - correct

Bug 3: Mutable Default in Condition Context

# Bug: comparing to a mutable default leaks state
DEFAULT = []

def validate(data, required=DEFAULT):
if data == required: # If required is mutable and modified...
return True

# Safe: use None sentinel
def validate(data, required=None):
if required is None:
required = []
return data == required

Bug 4: is Used for Value Comparison

# Bug: passes for small integers, fails for large ones
def is_success(code):
return code is 200 # SyntaxWarning in Python 3.8+

# Fix:
def is_success(code):
return code == 200

Bug 5: Shadowed Condition in if/elif

# Bug: second condition is logically unreachable
def classify(x):
if x > 0:
return "positive"
elif x > 10: # Never reached for x > 0
return "large positive" # Dead code
else:
return "non-positive"

# Fix: order more specific first
def classify(x):
if x > 10:
return "large positive"
elif x > 0:
return "positive"
else:
return "non-positive"

AI/ML Real-World Connection

Conditional logic is the backbone of data preprocessing and model evaluation pipelines.

import numpy as np

# Data validation pipeline - guard clause pattern
def preprocess_sample(sample):
"""Validate and preprocess a single training sample."""
# Guards at the top - fail fast on invalid data
if sample is None:
return None

if not isinstance(sample, (list, np.ndarray)):
return None

sample = np.array(sample, dtype=float)

if sample.size == 0:
return None

if np.any(np.isnan(sample)):
return None

if np.any(np.isinf(sample)):
return None

# Happy path: normalize valid sample
mean = sample.mean()
std = sample.std()

if std == 0: # Guard against division by zero
return sample - mean

return (sample - mean) / std


# Metric evaluation - elif chain with correct ordering
def evaluate_model_performance(accuracy):
"""Classify model performance tier."""
if accuracy >= 0.95:
return "production-ready"
elif accuracy >= 0.90:
return "good - needs monitoring"
elif accuracy >= 0.80:
return "acceptable - continue training"
elif accuracy >= 0.70:
return "poor - review architecture"
else:
return "failing - check data pipeline"

In scikit-learn pipelines, every transformer's transform() method begins with guards validating input shape, dtype, and value ranges - exactly the pattern this lesson teaches.

Watch: Python Truthiness and Control Flow

Interview Questions

Q1: What is the difference between if x and if x is not None?

Answer: if x tests truthiness - it evaluates to False for None, 0, "", [], {}, set(), and False. if x is not None tests only for None, so 0, "", and [] all pass. Use if x is not None when 0, empty string, or empty collection are valid values that should not be filtered out.

Q2: Why does the order of elif conditions matter?

Answer: Python evaluates if/elif top to bottom and executes the first block whose condition is true, then skips all remaining elif and else blocks. If you put a broad condition (like >= 70) before a specific one (like >= 90), any score of 90 or above will match the broad condition first, making the specific condition unreachable. Always order from most specific to most general.

Q3: What is a guard clause and why is it preferred over nested conditionals?

Answer: A guard clause is an early return (or raise) that exits a function when a precondition fails. Instead of if valid: ... main logic ..., you write if not valid: return error at the top, then the main logic at the bottom with no indentation. Guard clauses flatten nesting, reduce cognitive load, and make each failure case explicit and independently testable.

Q4: What will this print and why?

x = []
y = [1, 2, 3]

if x or y:
print("truthy")
else:
print("falsy")

Answer: "truthy". Python evaluates x or y using short-circuit evaluation. x = [] is falsy, so Python evaluates the right operand y = [1, 2, 3], which is truthy. The or expression returns y, which is truthy, so the if block executes.

Q5: What is the problem with this code?

def get_config(env):
if env == "prod":
return {"debug": False, "log_level": "ERROR"}
elif env == "dev" or env == "development":
return {"debug": True, "log_level": "DEBUG"}
elif env == "prod": # What is wrong here?
return {"debug": False, "log_level": "WARNING"}

Answer: The third elif env == "prod" is unreachable dead code - the first if env == "prod" already handles that case. Python will never reach the third branch. The code should be restructured to remove the duplicate condition.

Q6: When should you use ternary expression vs if/else?

Answer: Use ternary (a if condition else b) when the condition is simple, both values are short expressions, and there is no nesting. Use if/else when: either branch contains statements (not just values), the condition is complex, or you would need to nest ternaries. Nested ternaries are almost always harder to read and should be avoided.

Q7: How does Python determine if a custom object is truthy?

Answer: Python calls bool(obj) which invokes obj.__bool__(). If __bool__ is not defined, Python falls back to obj.__len__() - if length is 0 the object is falsy, otherwise truthy. If neither is defined, the object is always truthy. You can control an object's truthiness by implementing __bool__.

Quick Reference Cheatsheet

PatternUse whenExample
if x:Any falsy value should be rejectedif user:
if x is not None:Only None should be rejectedif count is not None:
if x is None:Guard against missing valueif config is None: return default
if x in (a, b, c):Value must be one of severalif status in ("ok", "pending"):
if a <= x <= b:Range checkif 0 <= age <= 120:
if not x:Value must be falsyif not errors: commit()
Guard clauseFlatten nestingif not valid: return None
TernaryShort binary choicelabel = "yes" if flag else "no"
elif orderingRangesMost specific → most general
if type(x) is T:Exact type checkif type(x) is int:
if isinstance(x, T):Type + subclass checkif isinstance(x, (int, float)):

Graded Practice Challenges

Level 1 - Predict the Output

x = 0
y = "hello"
z = []

print(bool(x))
print(bool(y))
print(bool(z))

if x or y or z:
print("truthy")
else:
print("falsy")
Show Answer

Output:

False
True
False
truthy

bool(0) is False. bool("hello") is True. bool([]) is False.

For x or y or z: x is falsy so Python checks y. y is truthy, so the or expression returns "hello" without checking z. The result "hello" is truthy, so the if block executes.

score = 73

if score >= 60:
result = "D"
elif score >= 70:
result = "C"
elif score >= 80:
result = "B"
elif score >= 90:
result = "A"
else:
result = "F"

print(result)
Show Answer

Output: "D"

73 >= 60 is True, so Python assigns result = "D" and skips all remaining elif blocks. Even though 73 >= 70 is also true, Python never evaluates it. This demonstrates why elif ordering must go most-specific first (90, 80, 70, 60) not least-specific first.

Level 2 - Debug the Code

Find the bug and fix it:

def classify_temperature(temp_celsius):
"""Classify temperature into human-readable categories."""
if temp_celsius > 0:
return "above freezing"
elif temp_celsius > 20:
return "warm"
elif temp_celsius > 35:
return "hot"
elif temp_celsius <= 0:
return "freezing or below"

Test: classify_temperature(40) should return "hot" but does not.

Show Answer

Bug: The condition temp_celsius > 0 is too broad - it matches any temperature above 0, including 20, 35, and 40. The elif temp_celsius > 20 and elif temp_celsius > 35 branches are unreachable for valid warm and hot temperatures.

Fixed version:

def classify_temperature(temp_celsius):
"""Classify temperature - most specific conditions first."""
if temp_celsius > 35:
return "hot"
elif temp_celsius > 20:
return "warm"
elif temp_celsius > 0:
return "above freezing"
else:
return "freezing or below"

# Verification
print(classify_temperature(40)) # "hot"
print(classify_temperature(25)) # "warm"
print(classify_temperature(10)) # "above freezing"
print(classify_temperature(-5)) # "freezing or below"
print(classify_temperature(0)) # "freezing or below"

Level 3 - Design Challenge

You are building a data validation function for a machine learning preprocessing pipeline.

The function receives a sample (could be anything) and must:

  1. Return None if the sample is None
  2. Return None if the sample is not a list or tuple
  3. Return None if the sample is empty
  4. Return None if any element is not a number (int or float)
  5. Return None if any element is NaN or inf
  6. Return the sample as a list of floats if all checks pass

Use guard clauses (not nested if statements). Every guard must be at the top of the function with no indentation deeper than one level.

Show Reference Solution
import math

def validate_sample(sample):
"""
Validate a single training sample for ML preprocessing.
Returns list of floats on success, None on any validation failure.
Uses guard clauses - all failures are early returns at the top.
"""
# Guard 1: reject None explicitly
if sample is None:
return None

# Guard 2: reject non-sequence types
if not isinstance(sample, (list, tuple)):
return None

# Guard 3: reject empty sequences
if len(sample) == 0:
return None

# Guard 4 + 5: validate each element
validated = []
for element in sample:
# Must be numeric
if not isinstance(element, (int, float)):
return None
# Must not be NaN or inf
if math.isnan(element) or math.isinf(element):
return None
validated.append(float(element))

# Happy path: return validated data
return validated


# Tests
print(validate_sample(None)) # None
print(validate_sample("text")) # None
print(validate_sample([])) # None
print(validate_sample([1, "a", 3])) # None
print(validate_sample([1, float('nan'), 3])) # None
print(validate_sample([1, 2, 3])) # [1.0, 2.0, 3.0]
print(validate_sample((4, 5, 6.0))) # [4.0, 5.0, 6.0]

Why guard clauses here?

Each validation concern is independent. If a new validation rule is needed (e.g., reject negative values), you add one guard clause at the top - no restructuring required. The happy path remains readable at the bottom.

Key Takeaways

  • Python evaluates truthiness at the object level - it calls __bool__() or __len__(), not just checking True/False
  • Every type has truthy and falsy values - knowing them prevents subtle bugs with empty containers and zero values
  • elif order determines correctness - more specific conditions must come before more general ones
  • Ternary expressions are clean for simple binary choices; nested ternaries are always unreadable
  • Each nesting level doubles the number of execution paths a reader must track
  • Guard clauses (early returns) eliminate nesting by inverting conditions at the top of functions
  • Use if x is not None: when 0, "", or [] are valid values that should not be falsy-rejected
  • In ML pipelines, guard-clause validation patterns protect every data ingestion and transformation step
© 2026 EngineersOfAI. All rights reserved.