Combining Conditions with Logical Operators

Python has three logical operators: and, or and not. They combine truthy and falsy values into a single boolean decision. Unlike the bitwise operators &, | and ~, the logical operators work on whole expressions and short-circuit: and stops at the first falsy operand, or stops at the first truthy one. Short-circuiting is not just an optimisation — it is the reason user and user.name is safe even when user is None.

Both and and or return one of the operands themselves, not always a boolean. 3 and 0 returns 0; 0 or 5 returns 5. This is how the classic "default value" idiom name = supplied or "guest" works: if supplied is falsy (empty string, None, zero), the second operand is used. The trade-off is that this treats every falsy value the same — if you care specifically about None, write supplied if supplied is not None else "guest" instead.

Operator precedence puts not highest, then and, then or. So a or b and c is a or (b and c), which rarely matches what the author meant. When combining three or more operators, add parentheses: they cost nothing and remove ambiguity for every reader. For complex conditions, extract a well-named boolean helper function; the condition becomes self-documenting and easy to test.

De Morgan's laws are worth knowing by heart: not (a and b) is equivalent to not a or not b, and not (a or b) is not a and not b. They let you flip an if/else so the positive case stays on top, which is usually clearer. Code that reads "if everything is fine, continue; otherwise stop" is easier to follow than the reverse.

Boolean conversion is governed by __bool__ (and, if it is missing, __len__). For custom classes, defining one of these turns your object into something if, while, and and or can all reason about. Keep the rule simple and memorable: an empty invoice, an inactive user, an uninitialised config — all reasonably return False.

Short-circuit evaluation in practice

Because and and or short-circuit, you can guard an expensive check with a cheap one: cache or recompute() calls recompute() only when the cache is empty. The same pattern prevents crashes: record is not None and record.active checks record.active only after confirming record is not None.

Bitwise vs logical operators

Beginners sometimes write & or | where they mean and/or. Bitwise operators work on the binary representation of integers and do not short-circuit: 0 | expensive() still evaluates the right-hand side. Pandas and NumPy reuse &/| for element-wise boolean arrays, which is why frameworks like those require parentheses in filter expressions: df[(df.a > 0) & (df.b < 10)].

These logical and boolean-related tools cover the vast majority of real conditionals.

ToolPurpose
and
operator
Returns the first falsy operand or the last one; short-circuits.
or
operator
Returns the first truthy operand or the last one; short-circuits.
not
operator
Inverts truthiness; always returns True or False.
truthiness
rule
What counts as True/False for any object.
any()
built-in
True if at least one item in an iterable is truthy.
all()
built-in
True if every item in an iterable is truthy (vacuously True for empties).
operator.truth()
function
Functional form of bool(); useful for functional-style filters.
x if cond else y
conditional expr
One-line ternary; picks x or y without side effects.

Combining Conditions with Logical Operators code example

The example uses and/or/not plus any() and all() to evaluate a small access policy.

# Lesson: Combining Conditions with Logical Operators
# Goal: express a membership rule clearly with small helper predicates.
from dataclasses import dataclass


@dataclass
class User:
    name: str
    active: bool
    age: int
    roles: list[str]


def is_adult(u: User) -> bool:
    return u.age >= 18


def has_any_role(u: User, roles: tuple[str, ...]) -> bool:
    return any(role in u.roles for role in roles)


def may_access(u: User) -> bool:
    '''Allowed when the user is active, an adult, and has at least one valid role.'''
    return (
        u.active
        and is_adult(u)
        and has_any_role(u, ("member", "staff"))
    )


# --- main script ---------------------------------------------------------
users = [
    User("Ada",   True,  36, ["staff"]),
    User("Ben",   False, 40, ["member"]),
    User("Cho",   True,  16, ["member"]),
    User("Dara",  True,  29, []),
]

for u in users:
    verdict = "allow" if may_access(u) else "deny"
    print(f"{u.name:<5}-> {verdict}")

print("any adult?", any(is_adult(u) for u in users))
print("all active?", all(u.active for u in users))

# short-circuit as a safe-navigation pattern
record = None
label = record.name if record is not None else "(missing)"
print(label)

Read the policy from top to bottom:

1) Each predicate (is_adult, has_any_role) answers one question only.
2) may_access() chains them with `and`; short-circuit stops at the first false.
3) `any()` / `all()` summarise a whole collection in one expression.
4) The label line uses a conditional expression instead of an if/else block.

Practice short-circuit patterns without the dataclass overhead.

# Example A: safe default with `or`
name = ""
shown = name or "anonymous"
print(shown)  # -> anonymous

# Example B: combine `any` / `all` with a generator
passwords = ["short", "looks-ok-123", "another-ok-45"]
strong = lambda p: len(p) >= 10 and any(c.isdigit() for c in p)
print(all(strong(p) for p in passwords))       # -> False
print([p for p in passwords if strong(p)])     # -> ['looks-ok-123', 'another-ok-45']

These assertions fix the trickier behaviours in place.

assert (0 and 1/0) == 0               # short-circuit avoids ZeroDivisionError
assert ("" or "fallback") == "fallback"
assert any([]) is False and all([]) is True  # vacuous truth
assert not (True and False)

Running the script prints:

Ada -> allow
Ben -> deny
Cho -> deny
Dara-> deny
any adult? True
all active? False
(missing)