Conditional Logic in Python: if, elif, else, and match/case

Picture this: you're building a recommendation engine that suggests movies to users. Every single recommendation involves dozens of invisible forks in the road, is this user a frequent watcher or occasional? Do they prefer thrillers or comedies? Have they already seen this film? Are they watching on a mobile device or a TV? Without the ability to evaluate conditions and branch accordingly, your program would be forced to treat every single user identically, spitting out the same suggestion regardless of any context. That's not a recommendation engine, that's a random number generator with extra steps. The moment you want your code to think, to respond, to make any decision whatsoever, you need conditional logic. It's the difference between a static webpage and a live application. Between a script that processes one hardcoded value and a tool that adapts to whatever you throw at it. Conditional logic is the nervous system of your program, and in Python it comes in three forms: the classic if/elif/else block, the compact ternary expression, and the sophisticated match/case pattern matching introduced in Python 3.10. In this article we'll cover all three in depth, not just the syntax, but the reasoning behind when to choose each one, the pitfalls that trip up beginners, and the patterns that separate workmanlike code from code that's genuinely elegant. By the end, you'll have the mental model to make smart decisions every time you reach for a conditional.
Table of Contents
- The Foundation: if, elif, else
- The Basic Syntax
- When You Have Multiple Paths: elif
- Boolean Values and Truthiness
- How Python Evaluates Conditions
- Nesting and the Case for Early Returns
- Ternary Expressions: Conditional Logic in One Line
- Truthiness and Falsy Values Deep Dive
- Pattern Matching with match/case (Python 3.10+)
- Match-Case: Python's Pattern Matching Revolution
- Literal Matching
- Matching Sequences (Lists and Tuples)
- Matching Dictionaries (Mappings)
- Guard Clauses in match/case
- Matching Class Instances
- Real-World Example: Processing API Responses
- Common Conditional Mistakes
- Common Anti-Patterns to Avoid
- Boolean Blindness
- Deep Nesting (The Pyramid of Doom)
- Using switch/case as if it were if/elif
- When to Use Each Tool
- Putting It All Together: Your Conditional Toolkit
The Foundation: if, elif, else
The Basic Syntax
Let's start with the simplest form: a single condition.
age = 16
if age >= 18:
print("You are an adult.")Expected output:
(no output)
Nothing printed because the condition age >= 18 is False. Python evaluates the expression, gets False, and skips the entire indented block beneath it, it moves on as if those lines don't exist. Now let's add the alternative path so something always happens:
age = 16
if age >= 18:
print("You are an adult.")
else:
print("You are a minor.")Expected output:
You are a minor.
The else block is your safety net, it runs whenever none of the preceding conditions were true. You don't give else a condition to evaluate; it's the "everything else" bucket, which is exactly what makes it so useful. Here's the critical bit: Python uses indentation to define code blocks. That four-space indent (or one tab) isn't just for prettiness, it's part of the syntax. Mess it up, and your code breaks.
if age >= 18:
print("You are an adult.") # SyntaxError: expected an indented blockYour IDE will help you with this, but it's worth knowing that indentation is non-negotiable in Python. This is a deliberate design choice by Guido van Rossum, by enforcing indentation, Python guarantees that your code structure is visually apparent, which makes it harder to accidentally write misleading code where a block looks nested but isn't.
When You Have Multiple Paths: elif
Real decisions are rarely binary. Should you approve a loan? That depends on credit score, income, debt-to-income ratio, employment history, and more. Should a game character run, walk, or crouch? That depends on the key being pressed. That's where elif (short for "else if") shines, it lets you chain as many conditions as you need, and Python will evaluate them in order until one matches.
score = 85
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"Your grade is: {grade}")Expected output:
Your grade is: B
Python evaluates these conditions top-to-bottom and stops at the first match. So with a score of 85, it skips the first condition (85 is not >= 90), checks the second (85 >= 80 is True), assigns grade to "B", and never looks at the remaining conditions. This short-circuit behavior is efficient and important to understand, the order of your conditions matters. If you had written the >= 60 check first, a score of 85 would match that condition and be graded "D", which is wrong. Always put more specific conditions before less specific ones.
Boolean Values and Truthiness
Before we go deeper, you need to understand truthiness. In Python, almost every value can be evaluated as True or False in a conditional context. This is one of Python's most powerful features, but also one of its sneakier sources of bugs if you're not aware of the rules.
if "hello":
print("Non-empty strings are truthy")
if []:
print("This won't print") # Empty lists are falsy
else:
print("Empty lists are falsy")
if 0:
print("This won't print") # Zero is falsy
else:
print("Zero is falsy")
if None:
print("This won't print") # None is falsy
else:
print("None is falsy")Expected output:
Non-empty strings are truthy
Empty lists are falsy
Zero is falsy
None is falsy
This is powerful and can make code elegant, but it can also hide bugs. Explicitly checking if len(my_list) > 0: is sometimes clearer than if my_list: depending on context. The rule of thumb: use truthiness when the intent is obvious ("does this list have any items?"), but prefer explicit comparisons when the meaning might be ambiguous to someone reading your code later, including future you.
How Python Evaluates Conditions
Understanding what happens under the hood when Python evaluates a conditional expression helps you write better code and debug problems faster. When Python encounters an if statement, it doesn't just check whether the expression is literally True or False. It calls the bool() function on whatever you give it, and that function follows a specific set of rules.
Every object in Python has a truth value. By default, instances of custom classes are truthy unless they define __bool__ or __len__ methods that return otherwise. For built-in types, the rules are consistent: zero values are falsy (0, 0.0, 0j), empty containers are falsy ([], , (), set(), ""), and None is always falsy. Everything else is truthy.
The evaluation also short-circuits for logical operators. When Python sees condition_a and condition_b, it evaluates condition_a first. If it's falsy, Python doesn't bother evaluating condition_b at all, the result is already determined. This matters in practice: you can use short-circuit evaluation to guard against errors. Writing if user and user.is_active is safe even if user is None, because Python stops at the first falsy value and never tries to access .is_active on None. The same logic applies to or, if the first condition is truthy, the second is never evaluated. Understanding this lets you write more expressive guards and default-value patterns without extra lines of code.
Nesting and the Case for Early Returns
You can nest conditionals as deeply as you want:
user_age = 25
has_license = True
has_insurance = True
if user_age >= 18:
if has_license:
if has_insurance:
print("You can rent a car.")
else:
print("You need insurance.")
else:
print("You need a license.")
else:
print("You're too young.")Expected output:
You can rent a car.
This code works, but look at the shape of it, each condition adds another level of indentation, pushing the actual logic further right. Developers call this the "pyramid of doom." It's hard to read, harder to modify, and a nightmare to debug when you need to add a fourth or fifth condition. A common and much cleaner refactoring is the early return pattern: check for failure conditions first and bail out immediately, so the happy path flows naturally at the bottom without nesting.
def can_rent_car(user_age, has_license, has_insurance):
if user_age < 18:
return False
if not has_license:
return False
if not has_insurance:
return False
return True
user_age = 25
has_license = True
has_insurance = True
if can_rent_car(user_age, has_license, has_insurance):
print("You can rent a car.")Expected output:
You can rent a car.
Much cleaner, right? By checking for problems first and returning early, we avoid deep nesting. Your code becomes more readable and easier to modify later. This pattern is sometimes called a guard clause. Each guard says "if this precondition isn't met, we're done here", and then we get out. The final return True at the bottom only runs when all guards have passed, which reads almost like plain English: check the age, check the license, check the insurance, then confirm it's okay. The logical flow is self-documenting.
Ternary Expressions: Conditional Logic in One Line
When you have a simple if-else that assigns a value, you can compress it into a ternary expression (also called a conditional expression). The goal isn't to show off how much you can squeeze onto one line, it's to avoid a four-line if-else block when the logic is simple enough that keeping it together improves readability.
age = 16
status = "adult" if age >= 18 else "minor"
print(status)Expected output:
minor
The syntax is: value_if_true if condition else value_if_false. It's concise and useful for simple assignments. Read it left to right: give me "adult" if the age is at least 18, otherwise give me "minor". Once you internalize that reading order, ternaries become extremely natural.
Here's a practical example: validating and providing defaults.
user_name = input("Enter your name (or press Enter for 'Guest'): ")
# Simulating user input as an empty string
user_name = ""
display_name = user_name if user_name else "Guest"
print(f"Welcome, {display_name}")Expected output:
Welcome, Guest
This pattern, using a ternary to provide a default when a value is falsy, shows up constantly in real Python code. You'll see it in web frameworks for handling optional form fields, in data processing scripts for filling in missing values, and in configuration code for applying defaults when settings aren't provided. It's a fundamental pattern worth memorizing.
You can even chain ternaries, though they get hard to read fast:
score = 75
grade = (
"A" if score >= 90 else
"B" if score >= 80 else
"C" if score >= 70 else
"D" if score >= 60 else
"F"
)
print(grade)Expected output:
C
This is equivalent to the elif chain we saw earlier, but formatted to be readable. However, if you need more than two or three branches, an if/elif/else block is often clearer than nested ternaries. The formatting trick here, putting each branch on its own line inside parentheses, helps, but even with good formatting there's a limit to how many conditions you can chain before a reader has to work hard to understand it. Use your judgment.
Truthiness and Falsy Values Deep Dive
We touched on truthiness earlier, but it deserves a proper deep dive because it affects nearly every conditional you write in Python. The core rule is simple: Python has a small set of values that are inherently falsy, and everything else is truthy. The falsy values are: False, None, zero in any numeric form (0, 0.0, 0j), empty sequences ("", [], (), b""), empty collections ({}, set()), and any object whose __bool__ method returns False or whose __len__ method returns 0.
This matters most in three situations. First, when checking whether a collection has items, if my_list: is idiomatic Python for "if this list is not empty," and most experienced Python developers prefer it to if len(my_list) > 0:. Second, when checking whether an optional value exists, if result: after a database query tells you whether you got anything back, as long as you're certain a valid result would be truthy. Third, when setting defaults, value = input_value or default_value uses the falsy nature of empty strings and None to fall back gracefully.
The trap people fall into: assuming that "no value" and "empty" are always falsy, and forgetting that 0 and False are falsy too. If your code checks if count: to see whether a count was provided, it will incorrectly treat a count of zero as "not provided." In that case, the explicit check if count is not None: is correct. Know the difference between "this value is missing" (use is None) and "this value is meaningful" (use truthiness). That distinction will save you from subtle bugs that are frustratingly hard to track down.
Pattern Matching with match/case (Python 3.10+)
This is where Python's conditional logic gets sophisticated. Introduced in Python 3.10, match/case is not just a switch statement, it's structural pattern matching. It can match literals, sequences, dictionaries, and custom objects. The mental model shift required here is significant but worth it: instead of asking "is this value equal to X?", you're asking "does this data have this structure?". That shift from value-matching to structure-matching unlocks a completely different way of writing decision logic.
Match-Case: Python's Pattern Matching Revolution
Before match/case, Python developers who wanted to dispatch on complex data structures had to write verbose chains of isinstance() checks, dictionary lookups, and nested conditions. The code worked, but it was ugly and hard to maintain. Python 3.10 changed that by borrowing ideas from functional languages like Haskell and Rust, where pattern matching is a first-class feature.
The key insight is that match/case goes beyond equality checking. When you write case (x, y, z):, you're not checking if your value equals some tuple, you're saying "if this value is a sequence of exactly three elements, bind those elements to x, y, and z." The matching and the extraction happen simultaneously. This is called destructuring, and it eliminates an entire category of tedious unpacking code. Instead of if isinstance(data, tuple) and len(data) == 3: x, y, z = data, you just write case (x, y, z): and Python handles all the checking and binding for you. The result is code that reads almost like a specification of what the data should look like, rather than a procedural checklist of conditions to verify.
Literal Matching
The simplest case: matching exact values.
command = "help"
match command:
case "help":
print("Showing help...")
case "quit":
print("Exiting...")
case "status":
print("Status: OK")
case _: # The underscore is the default case (catch-all)
print(f"Unknown command: {command}")Expected output:
Showing help...
The _ pattern matches anything and acts as the default. If you omit it and no case matches, the match statement does nothing (no error). This is intentionally different from some other languages where a missing default is an error, Python gives you the flexibility to handle only the cases you care about and silently ignore everything else.
Matching Sequences (Lists and Tuples)
Now it gets interesting. You can match on the structure of a sequence:
data = (1, 2, 3)
match data:
case (x, y, z):
print(f"Three elements: {x}, {y}, {z}")
case (x, y):
print(f"Two elements: {x}, {y}")
case [x]:
print(f"One element: {x}")
case _:
print("Some other sequence")Expected output:
Three elements: 1, 2, 3
The variables x, y, z are bound to the matched values. You can then use them in the case body. This is incredibly powerful for destructuring data, notice that we used (x, y, z) to match the tuple and [x] for a list, but Python's pattern matching actually treats both sequence types flexibly.
Here's a more practical example: parsing command-line arguments.
args = ["upload", "file.txt", "backup"]
match args:
case ["upload", filename]:
print(f"Uploading {filename}")
case ["upload", filename, destination]:
print(f"Uploading {filename} to {destination}")
case ["delete", filename]:
print(f"Deleting {filename}")
case ["list"]:
print("Listing files...")
case _:
print("Unknown command")Expected output:
Uploading file.txt to backup
In older Python, parsing argument lists like this required manual length checks and index lookups. With match/case, the structure of the data is self-evident in the code, and the extraction happens as part of matching, there's no separate unpacking step.
Matching Dictionaries (Mappings)
You can also match on dictionary structure:
user = {"name": "Alice", "age": 30, "role": "admin"}
match user:
case {"name": name, "role": "admin"}:
print(f"{name} has admin privileges")
case {"name": name, "role": "user"}:
print(f"{name} is a regular user")
case {"name": name}:
print(f"User {name} with unknown role")
case _:
print("Invalid user object")Expected output:
Alice has admin privileges
Here, we're matching on the presence and values of specific keys. name is bound to the value of the "name" key, and the role must be "admin" for this case to match. Partial matching is fine, we don't need to specify every key. This is a critical advantage over writing if user.get("role") == "admin": chains, because the pattern communicates the expected structure of the data as clearly as a type annotation would.
Guard Clauses in match/case
You can add conditions to cases with if:
point = (3, 4)
match point:
case (x, y) if x == y:
print(f"Point is on the diagonal: ({x}, {y})")
case (x, y) if x > y:
print(f"Point ({x}, {y}) is to the right of the diagonal")
case (x, y) if x < y:
print(f"Point ({x}, {y}) is above the diagonal")Expected output:
Point (3, 4) is above the diagonal
The guard clause refines the match. The pattern (x, y) matches any tuple of two elements, but only the case where x < y succeeds, so that branch executes. Guards give you the expressive power of arbitrary conditions while keeping the structural clarity of pattern matching, you get the best of both worlds.
Matching Class Instances
You can even match on custom class instances:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
class Circle:
def __init__(self, center, radius):
self.center = center
self.radius = radius
shape = Circle(Point(0, 0), 5)
match shape:
case Circle(center=Point(x=cx, y=cy), radius=r):
print(f"Circle at ({cx}, {cy}) with radius {r}")
case Point(x=x, y=y):
print(f"Point at ({x}, {y})")
case _:
print("Unknown shape")Expected output:
Circle at (0, 0) with radius 5
This destructures the shape object, extracting the nested coordinates in a single match expression. Imagine writing this without pattern matching: you'd need isinstance(shape, Circle), then isinstance(shape.center, Point), then extract shape.center.x and shape.center.y. The match version reads like a description of the data structure you expect, which is infinitely more maintainable.
Real-World Example: Processing API Responses
Let's bring this all together with a realistic scenario: handling an API response.
import json
# Simulating an API response
response = {
"status": "success",
"data": [
{"id": 1, "name": "Product A", "price": 29.99},
{"id": 2, "name": "Product B", "price": 49.99}
]
}
match response:
case {"status": "success", "data": items}:
print(f"Got {len(items)} items:")
for item in items:
match item:
case {"id": id, "name": name, "price": price} if price < 40:
print(f" [{id}] {name}: ${price} (SALE)")
case {"id": id, "name": name, "price": price}:
print(f" [{id}] {name}: ${price}")
case {"status": "error", "message": msg}:
print(f"Error: {msg}")
case {"status": code}:
print(f"Unknown status: {code}")
case _:
print("Invalid response format")Expected output:
Got 2 items:
[1] Product A: $29.99 (SALE)
[2] Product B: $49.99
We match on the overall response structure, extract the data, then iterate and match on each item. The guard clause if price < 40 filters for sale items. This is clean, expressive, and Python does it all in one construct. Notice how each case branch tells you exactly what shape of data it handles, the code is almost self-documenting.
Common Conditional Mistakes
Even experienced developers fall into the same traps with conditionals. Let's walk through the most common ones so you can recognize and avoid them.
The first mistake is comparing to True or False explicitly, writing if is_active == True: instead of if is_active:. The explicit comparison is redundant and actually considered non-Pythonic. Boolean values are already true or false; checking if a boolean equals True adds noise without adding information. Use if is_active: and if not is_active: instead.
The second mistake is mutating data inside conditions. Doing something like if (n := len(data)) > 10: with the walrus operator is valid, but accidentally writing code that changes your data as a side effect of checking it is a sure path to subtle bugs. Keep conditions pure whenever possible, they should observe state, not change it.
The third mistake is overlapping conditions in the wrong order. If you write elif score >= 60: before elif score >= 80:, every score from 60 to 100 will match the first condition, and the 80+ case will never be reached. Always put more specific (narrower) conditions before less specific (broader) ones.
The fourth mistake is forgetting that assignment is not comparison. In languages like C or JavaScript, writing if (x = 5) instead of if (x == 5) is a common bug that accidentally assigns rather than checks. Python usually catches this with a syntax error, but the walrus operator := makes intentional assignment in conditions possible, so be careful when reading code that uses it that you understand whether you're seeing assignment or comparison.
The fifth mistake is over-relying on truthiness for None checks. If 0 and False are valid values in your data, checking if value: will incorrectly treat them as "missing." Use if value is not None: when you want to specifically check for None rather than any falsy value.
Common Anti-Patterns to Avoid
Boolean Blindness
Don't return boolean results directly:
# Anti-pattern
def is_valid_email(email):
if "@" in email and "." in email:
return True
else:
return False
# Better
def is_valid_email(email):
return "@" in email and "." in emailThe second version is cleaner, you're already computing a boolean, so just return it. The first version makes Python evaluate the expression to get a boolean, then immediately convert it back to a boolean via the if-else, which is pure ceremony with no benefit.
Deep Nesting (The Pyramid of Doom)
# Anti-pattern: deeply nested
if condition1:
if condition2:
if condition3:
if condition4:
do_something()
# Better: guard clauses
if not condition1:
return
if not condition2:
return
if not condition3:
return
if not condition4:
return
do_something()Early returns flatten the pyramid and make your intent clearer. Each guard clause handles one specific failure mode and gets out. The reader can see exactly why the function might fail early, and the happy path at the bottom is clean and obvious.
Using switch/case as if it were if/elif
Python's match/case is more powerful than a traditional switch, but don't force every decision into it. Simple if/elif is often better for straightforward conditions:
# Invalid syntax: case clauses cannot use comparison expressions
match True:
case age >= 18:
status = "adult"
case age >= 13:
status = "teen"
case _:
status = "child"The code above is not just bad style, it is actually invalid Python syntax. The case clause expects a pattern, not an arbitrary boolean expression like age >= 18. This would raise a SyntaxError if you tried to run it. Use if/elif/else instead:
# Better
if age >= 18:
status = "adult"
elif age >= 13:
status = "teen"
else:
status = "child"The second version is more readable for range-based checks. Use match when you have complex pattern matching or multiple data types to destructure, not just because it's newer or looks sophisticated.
When to Use Each Tool
Use if/elif/else when:
- You're checking ranges or comparisons (
age >= 18) - You have a small number of branches (2-4)
- The logic is simple and linear
Use ternary expressions when:
- You're assigning one of two values based on a condition
- The expression fits comfortably on one line
- You want to avoid a verbose if/else block
Use match/case when:
- You're destructuring complex data structures
- You have multiple patterns to match (different types or shapes)
- You need partial matching on dictionaries or objects
- Your code involves type-based dispatch or pattern-based routing
Putting It All Together: Your Conditional Toolkit
You now have a complete toolkit for every decision your Python programs will ever need to make. The classic if/elif/else is your workhorse, reach for it when you're evaluating conditions sequentially, when you're checking ranges, or when simple true/false logic is all you need. Keep the guard clause pattern in your back pocket for every function that has preconditions; it will save you from tangled nesting more times than you can count.
Ternary expressions are the scalpel in the toolkit, precise, sharp, and best used sparingly on small, well-understood decisions. When you need to inline a simple assignment and the logic is unambiguous, a ternary makes your code more compact without sacrificing clarity. When the logic gets complex or the line gets long, put the scalpel down and pick up a proper if-else block. Brevity is only a virtue when it doesn't cost readability.
And then there's match/case, the newest and most powerful member of the family. If you're working with Python 3.10 or later (which you should be, for any new project), pattern matching will gradually reshape how you think about handling structured data. The shift from "check this value against these options" to "match this data against these shapes" is a genuine improvement in expressiveness, and it pays off most in exactly the kinds of programs you'll be writing as you move into AI/ML work, parsing API responses, processing records, dispatching on message types, routing requests based on their content. These are all pattern-matching problems, and Python now has a first-class tool for them.
The underlying principle across all three forms is the same: write conditions that make your intent clear. A reader of your code should be able to understand what you're checking and why without needing a comment to explain it. When your conditions are structured well, specific before general, guards before happy paths, patterns that mirror the shape of your data, the code becomes its own documentation. That's the goal we're always working toward.
In the next article, we'll tackle loops, the other pillar of program control. You'll learn for and while, understand iteration, and master the patterns that process collections of data. See you there!