How to Write Multiple Conditions in Python: From Boolean Logic to Pattern Matching

Almost every useful program you will ever write needs to make decisions. Should the user be allowed in? Is the input valid? Has the retry limit been exceeded? Each of those questions translates to a condition in your code, and the moment a single boolean check is not enough, you are writing multiple conditions.

Python provides an unusually rich set of tools for combining conditions. Some of them -- like the and and or operators -- look familiar from other languages but behave in subtly different ways. Others, like chained comparisons and structural pattern matching, are distinctive features that exist because the language's designers prioritized readability above almost everything else.

"Readability counts."

— Tim Peters, The Zen of Python (PEP 20), posted to the Python mailing list, 1999

That two-word aphorism, the seventh principle in the Zen of Python, has influenced every decision Python has ever made about conditional syntax. This article walks through every major technique for writing multiple conditions, explains the PEPs and design debates behind each one, and gives you the patterns to make the right choice in your own code.

Why Multiple Conditions Matter

A condition is any expression Python evaluates as truthy or falsy. A single condition is straightforward: if x > 0. But real-world logic almost always involves combining several checks. Should you grant access? That depends on whether the user is authenticated and has the right role and the account is not suspended. Should you retry a request? That depends on whether the status code indicates a server error or a timeout and you have not exceeded the retry limit.

The challenge is not just getting the logic right -- it is expressing it in a way that another developer (or your future self) can read six months from now without a decoder ring. Every technique in this article is a different answer to that challenge.

The Boolean Operators: and, or, not

Python's three boolean operators are the foundation of every multi-condition expression. They use English words instead of the symbols (&&, ||, !) found in C-family languages, and that choice was deliberate. Python consistently favors readable English keywords over cryptic punctuation.

and -- Both Conditions Must Be True

age = 25
has_license = True

if age >= 18 and has_license:
    print("Eligible to drive")

# Multiple and conditions
user = {"active": True, "role": "admin", "verified": True}

if user["active"] and user["role"] == "admin" and user["verified"]:
    print("Full admin access granted")

or -- At Least One Must Be True

status_code = 503

if status_code == 500 or status_code == 502 or status_code == 503:
    print("Server error -- retrying")

not -- Inverts the Condition

is_banned = False

if not is_banned:
    print("User is welcome")

# Combining all three
if (age >= 18 and has_license) and not is_banned:
    print("Cleared to proceed")
Operator Precedence

not binds tightest, then and, then or. So a or b and c is evaluated as a or (b and c), not (a or b) and c. When mixing and and or in the same expression, always use parentheses to make the grouping explicit. PEP 8, Python's official style guide, encourages parentheses for readability even when they are not strictly required.

Short-Circuit Evaluation: Why Python Stops Early

One of the most important behaviors of and and or is short-circuit evaluation. Python stops evaluating a compound condition the moment the overall result is determined.

With and, if the first operand is falsy, Python returns it immediately without ever evaluating the second operand -- because no matter what the second value is, the combined result is already falsy. With or, if the first operand is truthy, Python returns it immediately -- the combined result is already truthy.

# Short-circuit with and -- avoids ZeroDivisionError
x = 0
if x != 0 and 10 / x > 2:
    print("This is safe")
# x != 0 is False, so Python never evaluates 10 / x

# Short-circuit with or -- skips expensive function
cached_result = None
result = cached_result or expensive_database_query()
# If cached_result is truthy, the function never runs

Here is the critical detail that separates Python from many other languages: Python's and and or do not return True or False. They return the actual operand that determined the result. The and operator returns the first falsy value it encounters, or the last value if all are truthy. The or operator returns the first truthy value, or the last value if all are falsy.

# and returns the determining value
print("hello" and "world")   # "world" (both truthy, returns last)
print("" and "world")        # ""      (first is falsy, returns it)
print(0 and 42)              # 0       (first is falsy, returns it)

# or returns the determining value
print("hello" or "world")    # "hello" (first is truthy, returns it)
print("" or "world")         # "world" (first is falsy, tries second)
print(0 or None)             # None    (both falsy, returns last)

This behavior is what made the pre-PEP-308 hack of condition and value_if_true or value_if_false possible, and it is also what made that hack unreliable. If value_if_true happened to be a falsy value like 0, "", or False, the expression would silently return value_if_false instead. This exact problem was cited in PEP 308 as one of the driving reasons Python needed a proper conditional expression.

Chained Comparisons: Python's Hidden Superpower

Most languages force you to write range checks as two separate conditions joined by and. Python lets you chain comparisons directly, and the interpreter expands them into the equivalent and expression behind the scenes.

score = 85

# Instead of this:
if score >= 70 and score <= 90:
    print("B grade")

# Write this:
if 70 <= score <= 90:
    print("B grade")

# Works with any comparison operator, even mixed
a, b, c = 1, 2, 3
if a < b < c:         # True: strictly ascending
    print("Ascending")

if a < b <= c != d:   # Chains as many as you want
    print("All conditions met")

# Equality chains work too
x = y = z = 5
if x == y == z:
    print("All equal")         # True

A chained comparison like a < b < c is equivalent to a < b and b < c, with the crucial difference that b is only evaluated once. This matters when b is an expensive function call or has side effects.

This syntax reads like mathematical notation, which is exactly the point. Guido van Rossum's original design goals for Python, as outlined in his 1999 DARPA proposal titled "Computer Programming for Everybody," included making the language easy and intuitive, with syntax that mirrors how humans think about problems rather than how machines execute them.

Real-World Pattern

Chained comparisons are ideal for range validation: if 0 <= index < len(data) is safer and cleaner than checking each bound separately. You will see this pattern constantly in data processing, input validation, and boundary checking code.

if / elif / else Chains

When you need to test a sequence of mutually exclusive conditions, the if/elif/else chain is Python's workhorse. Each condition is tested in order, and only the first matching block executes.

def classify_temperature(temp):
    if temp >= 100:
        return "boiling"
    elif temp >= 80:
        return "very hot"
    elif temp >= 60:
        return "warm"
    elif temp >= 40:
        return "mild"
    elif temp >= 20:
        return "cool"
    else:
        return "cold"

Python uses short-circuit evaluation in elif chains: once a condition matches, none of the remaining conditions are even evaluated. This matters if your conditions have side effects, but more importantly it means you should order your conditions from most likely to least likely in performance-sensitive code.

Note that Python deliberately chose not to implement a switch or case statement for most of its history. PEP 275 (2001) and PEP 3103 (2006) both proposed switch statements and were both rejected. Guido van Rossum argued that if/elif/else chains were clear enough and that a switch statement would add complexity without proportional benefit. It was not until PEP 634 (structural pattern matching) in Python 3.10 that Python gained anything resembling a switch -- and as we will see later, match/case is far more powerful than a traditional switch.

The Conditional Expression: Python's Ternary (PEP 308)

PEP 308, co-authored by Guido van Rossum and Raymond Hettinger, has one of the most dramatic histories of any Python Enhancement Proposal. First proposed in February 2003, it triggered what the PEP text itself described as "unprecedented community response." A community vote offered 17 different syntax options (from C ? x : y to x when C else y) and no single option gained a clear majority. The PEP was initially rejected.

In September 2005, after the condition and true_value or false_value hack continued to cause bugs in real codebases, Guido exercised his BDFL authority and chose the syntax that ultimately shipped in Python 2.5:

# Syntax: value_if_true if condition else value_if_false

age = 20
status = "adult" if age >= 18 else "minor"

# Works in assignments, function arguments, return values
print("even" if x % 2 == 0 else "odd")

# Nested ternaries (use sparingly)
grade = ("A" if score >= 90
         else "B" if score >= 80
         else "C" if score >= 70
         else "F")

The syntax puts the condition in the middle (x if C else y) rather than at the front (C ? x : y). Many developers initially found this uncomfortable. But Guido's reasoning, as documented in the Python 2.5 "What's New" notes, was that the syntax was tested against real standard library code and the results read more naturally. In most real usage, one value is the "common case" and the other is the "exception," and the syntax puts the common case first.

"A Python design principle has been to prefer the status quo whenever there are doubts about which path to take."

— PEP 308, Conditional Expressions, peps.python.org

That principle, stated directly in PEP 308 when the original vote failed to produce a majority, captures why Python is conservative about syntax changes. The ternary expression took over two years and multiple rounds of community debate before it was added. Compare that to JavaScript, which inherited C's ternary operator in its first release.

Keep Ternaries Simple

Nested ternary expressions are legal Python but quickly become unreadable. If you find yourself nesting more than one level deep, switch to an if/elif/else block. The Zen of Python (PEP 20) warns: "Flat is better than nested."

any() and all(): Conditions Over Collections

When you have a large or dynamic number of conditions, hardcoding them into an and/or chain is impractical. Python's built-in any() and all() functions solve this elegantly. They accept any iterable of values and return a single boolean result.

permissions = ["read", "write", "execute"]
required = ["read", "write"]

# all() -- every condition must be true (like chained and)
if all(perm in permissions for perm in required):
    print("All permissions granted")

# any() -- at least one must be true (like chained or)
error_codes = [200, 301, 500, 200]
if any(code >= 500 for code in error_codes):
    print("At least one server error occurred")

Both functions use short-circuit evaluation internally. all() stops at the first falsy value, and any() stops at the first truthy value. When paired with generator expressions (note the lack of square brackets), they evaluate lazily -- the iterable is consumed only as far as necessary to determine the result.

# Validate multiple fields at once
form_data = {"name": "Kandi", "email": "[email protected]", "age": "25"}

required_fields = ["name", "email", "age"]
is_valid = all(
    field in form_data and form_data[field].strip()
    for field in required_fields
)

# Check if any value in a dataset is an outlier
readings = [22.1, 21.8, 99.9, 22.3, 21.5]
has_outlier = any(r > 50 or r < 0 for r in readings)

Think of all() as a programmatic replacement for a long chain of and operators, and any() as the same for or. The difference is that the conditions can be generated dynamically from data rather than hard-coded into your source.

Membership and Identity Tests

When you need to check a value against multiple possibilities, the in operator with a set or tuple is far cleaner than chaining or comparisons.

status_code = 503

# Instead of this:
if status_code == 500 or status_code == 502 or status_code == 503:
    print("Server error")

# Write this:
if status_code in {500, 502, 503}:
    print("Server error")

# Works with strings, tuples, any container
ext = ".py"
if ext in (".py", ".pyw", ".pyi"):
    print("Python file")

Using a set literal ({500, 502, 503}) for membership tests is not just more readable -- it is also faster. Set membership testing is O(1) on average, while checking against a list or chaining or comparisons is O(n). For small sets the difference is negligible, but the readability improvement is immediate.

For identity checks, Python provides is and is not. These test object identity, not equality, and are primarily used for comparing against singletons like None, True, and False.

result = some_function()

# Correct: identity check against None
if result is not None:
    process(result)

# Incorrect: equality check against None
if result != None:  # Works but violates PEP 8
    process(result)

PEP 8 is explicit on this point: comparisons to singletons like None should always use is or is not, never the equality operators. The reason is that a custom class could override __eq__ and make x == None return True even when x is not actually None.

The Walrus Operator in Conditions (PEP 572)

PEP 572, accepted in July 2018, introduced the walrus operator (:=) for assignment expressions. It lets you assign a value inside a condition, avoiding repeated computation or redundant function calls.

# Without walrus: call the function twice or use temp variable
data = get_data()
if data:
    process(data)

# With walrus: assign and test in one expression
if (data := get_data()):
    process(data)

# Powerful in while loops with conditions
while (line := file.readline()).strip():
    print(line)

# In complex multi-condition expressions
if (user := find_user(user_id)) and user.is_active():
    grant_access(user)

PEP 572 was the most controversial PEP of its era. The intensity of the debate directly led to Guido van Rossum stepping down as BDFL on July 12, 2018. Despite that turbulence, the walrus operator has proven genuinely useful in conditional expressions, particularly in the pattern of "compute a value, test it, and use it if it passes."

Structural Pattern Matching with Guards (PEP 634)

Python 3.10 introduced structural pattern matching through PEP 634, authored by Brandt Bucher and Guido van Rossum. It is described by three separate PEPs: PEP 634 (specification), PEP 635 (motivation and rationale), and PEP 636 (tutorial). The feature went through an earlier proposal (PEP 622) that was withdrawn and substantially revised.

Pattern matching lets you test the shape and content of data in a way that replaces complex if/elif chains with declarative patterns. The case blocks can include guards -- additional if conditions that must also be true for the case to match.

def handle_command(command):
    match command.split():
        case ["move", direction] if direction in {"north", "south", "east", "west"}:
            print(f"Moving {direction}")
        case ["attack", target] if target != "self":
            print(f"Attacking {target}")
        case ["quit"]:
            print("Goodbye")
        case _:
            print("Unknown command")

# Matching on type and structure simultaneously
def process_event(event):
    match event:
        case {"type": "click", "x": x, "y": y} if 0 <= x <= 1920 and 0 <= y <= 1080:
            print(f"Valid click at {x}, {y}")
        case {"type": "keypress", "key": key} if len(key) == 1:
            print(f"Key pressed: {key}")
        case _:
            print("Unhandled event")

Guards are the bridge between pattern matching and traditional conditional logic. The pattern checks structure (does this data look like a click event with x and y coordinates?), and the guard adds value-based conditions (are those coordinates within the screen bounds?). PEP 634 specifies that guards are evaluated in order and that evaluation stops once a case block is selected.

PEP 635, the rationale document, notes that the if/elif/else idiom was already commonly used to perform ad-hoc type and shape checking with constructs like isinstance(), hasattr(), and key in dict. Pattern matching replaces those imperative checks with a declarative style that states the conditions for data to match, rather than procedurally testing each attribute one by one.

Truthiness and Its Traps

Every object in Python has a truth value. By default, an object is considered truthy unless its class defines a __bool__() method that returns False or a __len__() method that returns zero. The following values are falsy: None, False, zero in any numeric type (0, 0.0, 0j), empty sequences ("", (), []), empty mappings ({}), and empty sets.

# Truthy/falsy as implicit conditions
items = [1, 2, 3]

if items:              # Truthy -- list is non-empty
    print("Has items")

name = ""
if not name:          # Truthy -- empty string is falsy
    print("Name is empty")

The Zen of Python says "Explicit is better than implicit." Relying on truthiness is convenient but can create subtle bugs. Consider if count: -- this is False when count is 0, but 0 is a perfectly valid count. If you mean "if count is not None," write exactly that. If you mean "if count is greater than zero," write if count > 0.

The Classic Trap

if x: is not the same as if x is not None:. The first catches None, 0, "", [], False, and any other falsy value. The second catches only None. Choose based on what you actually mean to test.

Readability and Refactoring Complex Conditions

The moment a condition spans more than about 80 characters or combines more than three tests, it is time to refactor. Python gives you several strategies.

Strategy 1: Extract Named Booleans

Give each sub-condition a descriptive variable name. The if statement then reads like English.

# Hard to read
if user.age >= 18 and user.is_verified and not user.is_banned and user.role in ("admin", "moderator"):
    grant_access()

# Readable
is_adult = user.age >= 18
is_trusted = user.is_verified and not user.is_banned
has_permission = user.role in ("admin", "moderator")

if is_adult and is_trusted and has_permission:
    grant_access()

Strategy 2: Use Early Returns

Instead of nesting conditions deeper and deeper, invert the logic and return early.

# Deeply nested (hard to follow)
def process_order(order):
    if order is not None:
        if order.is_paid:
            if order.items:
                ship(order)

# Flat with early returns (easy to follow)
def process_order(order):
    if order is None:
        return
    if not order.is_paid:
        return
    if not order.items:
        return
    ship(order)

This pattern directly embodies the Zen of Python's principle that "flat is better than nested." Each guard clause eliminates an invalid case and reduces the indentation level for the remaining logic.

Strategy 3: Extract a Predicate Function

When the same compound condition appears in multiple places, extract it into a function.

def is_eligible_for_discount(customer) -> bool:
    return (
        customer.is_member
        and customer.orders_count >= 5
        and customer.account_age_days >= 90
    )

# Now the calling code reads like English
if is_eligible_for_discount(customer):
    apply_discount(order)

"If the implementation is easy to explain, it may be a good idea."

— Tim Peters, The Zen of Python (PEP 20), 1999

Predicate functions are the ultimate expression of this principle. If you can name a complex condition with a function that reads like a sentence, the code explains itself.

Key Takeaways

  1. and, or, not are your everyday tools: They are readable English keywords by design, they short-circuit, and they return the determining operand rather than a bare boolean. Always use parentheses when mixing and and or.
  2. Chained comparisons make range checks readable: 0 <= x < 100 is both more Pythonic and more efficient than x >= 0 and x < 100, because the middle operand is evaluated only once.
  3. PEP 308's ternary took two years of debate: The x if C else y syntax was chosen by Guido after a 17-option community vote failed to reach majority. It puts the common-case value first, which reads more naturally in most real code.
  4. any() and all() scale to dynamic conditions: When your conditions come from data rather than hardcoded logic, these built-ins replace chains of and/or with a single expressive call that short-circuits lazily.
  5. Use in with sets for multi-value checks: if x in {a, b, c} is cleaner and faster than chaining x == a or x == b or x == c.
  6. Pattern matching (PEP 634) handles shape and value together: When your branching logic depends on the structure of data -- its type, its keys, its length -- match/case with guards replaces complex if/elif chains with declarative patterns.
  7. Refactor for readability above all: Extract named booleans, use early returns, and write predicate functions. The Zen of Python's three most relevant principles for conditions -- "Readability counts," "Flat is better than nested," and "Explicit is better than implicit" -- all point toward code that another human can understand on the first read.

Python's conditional toolbox is broad because the language's designers recognized that no single construct fits every situation. Simple boolean checks, chained comparisons, ternary expressions, collection-based tests, walrus-powered assignments, and structural pattern matching each occupy a distinct niche. The art is not memorizing all of them -- it is knowing which tool produces the clearest code for the specific decision your program needs to make. Write conditions that read like sentences, and six months from now your future self will thank you.

cd ..