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.
| Tool | Purpose |
|---|---|
andoperator | Returns the first falsy operand or the last one; short-circuits. |
oroperator | Returns the first truthy operand or the last one; short-circuits. |
notoperator | Inverts truthiness; always returns True or False. |
truthinessrule | 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 yconditional 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)