PYTHON / OPERATORS
Logical operators and short-circuit evaluation
Predict exactly what and, or, and not return, and use short-circuit evaluation to guard risky expressions and supply fallback values.
What you will learn
- Know that and/or return one operand, while not always returns True or False
- Order an and/or chain so the safe or cheap test blocks the risky one
- Spot when x or default silently swallows valid falsy values like 0, "", []
- Trace which function calls and side effects are skipped by short-circuiting
Understanding Logical operators and short-circuit evaluation
Python's and and or are not boolean-returning functions; they are selectors that hand back one of their operands. For a and b, Python evaluates a, tests its truthiness, and returns a unchanged if it is falsy, otherwise returns b. For a or b it returns a if a is truthy, otherwise b. That is why 3 and 5 gives 5, why 0 or [] gives [], and why the result can be a list, a string, or None rather than True or False. Only not is guaranteed to produce a bool.
Short-circuiting falls straight out of that rule: if the left operand already decides the answer, the right operand is never evaluated at all. In a and b, a falsy left side means the whole thing is falsy no matter what b is, so b is not touched. In a or b, a truthy left side ends it. This is not an optimisation detail you can ignore, it is language-guaranteed behaviour you can build on: put a safety check on the left of and, and the dangerous expression on the right will never run when the check fails.
The practical trap is truthiness. Falsy values include False, None, 0, 0.0, "", (), [], {} and set(), so the popular default idiom value = value or fallback replaces an empty string or a zero just as eagerly as it replaces None. When zero or an empty container is a legitimate value, test explicitly with is None instead. Similarly, remember that side effects on the short-circuited side are skipped, so never hide a needed function call, counter increment, or assignment expression on the right of and or or.
def check(label, value):
print("evaluating", label)
return value
# and stops at the first falsy operand
result = check("a", 0) and check("b", 99)
print("and result:", result)
# or stops at the first truthy operand
result = check("c", "yes") or check("d", "no")
print("or result:", result)
# the result is an operand, not a bool
print(3 and 5, 0 or [], "" or "fallback")
print(not 0, not "text")and and or return one of their operands and evaluate the right-hand side only when the left-hand side has not already decided the result.
Worked examples
Guarding a risky expression
Shows how putting the cheap test first with and prevents an exception, and how reversing the order breaks it.
values = []
if len(values) > 0 and values[0] > 10:
print("first is big")
else:
print("no big first element")
data = {"count": 3}
if "total" in data and data["total"] > 0:
print("has total")
else:
print("no usable total")
try:
if values[0] > 10 and len(values) > 0:
print("unreachable")
except IndexError as e:
print("IndexError:", e)Example explained
Line 1len(values) > 0 is False, so values[0] is never evaluated and no IndexError can occur.
Line 2"total" in data is False, so the dictionary lookup data["total"] is skipped instead of raising KeyError.
Line 3Swapping the operands puts values[0] on the left, where it is always evaluated, so the guard is useless.
Line 4The lesson: with and, the operand that can fail must sit to the right of the operand that rules it out.
The falsy-default bug
Compares the x or default idiom with an explicit None check when 0 is a legitimate value.
def resolve(port):
return port or 8080
def resolve_fixed(port):
return 8080 if port is None else port
print(resolve(None), resolve(0), resolve(9000))
print(resolve_fixed(None), resolve_fixed(0), resolve_fixed(9000))Example explained
Line 1resolve(None) works as intended: None is falsy, so or returns the right operand 8080.
Line 2resolve(0) is the bug: 0 is also falsy, so a deliberately chosen port 0 is thrown away.
Line 3resolve_fixed asks the precise question, is the value None, so 0 passes through untouched.
Line 4Use x or default only when every falsy value really should be replaced.
any and all short-circuit too
Demonstrates that any behaves like a chain of or and all like a chain of and, stopping as soon as the answer is known.
def loud(n):
print("testing", n)
return n % 2 == 0
print(any(loud(n) for n in [1, 2, 3, 4]))
print(all(loud(n) for n in [2, 4, 5, 6]))Example explained
Line 1any pulls from the generator until loud(2) returns True, so 3 and 4 are never tested.
Line 2all stops at loud(5), the first False, so 6 is never tested.
Line 3Unlike or and and, any and all return a real bool, not the item that decided the outcome.
Line 4Because they stop early, passing a generator instead of a list means the unneeded items are never even produced.
Important notes
not always yields a real bool, so bool(x) and not not x are equivalent; and/or never coerce, so wrap them in bool() if a caller demands True or False.
not binds tighter than and, which binds tighter than or, and all comparisons bind tighter still: not a == b means not (a == b), and a or b and c means a or (b and c).
Common mistakes
Writing if x == 1 or 2: to mean "x is 1 or 2". Python evaluates (x == 1) or 2, and 2 is truthy, so the condition is True for every x and the branch always runs.
Assuming and/or return booleans, then writing checks like if (a and b) == True: or storing the result as a flag. You get the operand back, so "hi" and 0 stores 0 and 3 and 5 stores 5, and the == True comparison fails on perfectly truthy values.
Hiding a required call on the short-circuited side, e.g. if ok and log_attempt():. When ok is False the logging never happens, and the missing side effect is invisible in the code's shape.
Try it yourself
Change, predict, then run
Write first_word(text) that returns the first whitespace-separated word of text, or the string "(empty)" when there is none, using or so the fallback is chosen only when text.split() returns an empty list. Test it with "hello world", " " and "".
Open the Python workspaceCheck your understanding
What does print("" or 0 or []) display, and why?
- [] — every operand is falsy, so or returns the last one it evaluated
- False — or converts its result to a boolean before returning it
- "" — or always returns its leftmost operand
- 0 — or returns the first falsy operand it finds
Show answer
or returns its left operand when that operand is truthy, otherwise it moves right; with "" and 0 both falsy it ends up returning the final operand [], which prints as []. False is tempting because the whole expression is falsy, but or never converts anything to bool — only not and functions like any/all do that.