Chained Comparison and Operator Precedence Surprises
Python’s grammar makes two decisions that read beautifully in the easy cases and bite hard in the corner cases. First, comparisons chain:
0 <= x <= 10means exactly what a mathematician expects,0 <= x and x <= 10, withxevaluated only once. Second, Python assigns every operator a fixed precedence, and several of those bindings contradict the intuition C or arithmetic gives you — most notoriously that the bitwise operators&and|bind tighter than==, and that**binds tighter than a leading unary minus so that-2 ** 2is-4. The traps in this note are not bugs; they are the grammar working exactly as specified, producing a result that looks like it should mean something else. Because the code still runs — often returning a plausible-looking value rather than raising — these are stealth bugs that survive review. This note catalogs the traps, shows the realdisbytecode that proves the evaluation order, and gives the fix (which is almost always “add parentheses”). The underlying dispatch mechanism — how<,&, and**route to__lt__,__and__, and__pow__— is Operator Overloading and the Numeric Protocols; the opcodes you will see disassembled are catalogued in Python Bytecode Instruction Set.
The Precedence Table That Causes the Surprises
Python’s operator precedence table (lowest binding at top, highest at bottom) contains the order that traps people. The relevant rungs, verbatim from the 3.14 reference:
| Binding | Operators |
|---|---|
| loosest | or |
and | |
not x | |
comparisons, in, not in, is, is not, <, <=, >, >=, !=, == | |
| (bitwise OR) | |
^ (bitwise XOR) | |
& (bitwise AND) | |
<<, >> | |
+, - (binary) | |
*, @, /, //, % | |
+x, -x, ~x (unary) | |
| tightest | ** |
The two surprising facts are visible directly in the table: bitwise & ^ | sit below (bind tighter than) the comparison row, and ** sits at the very bottom, tighter than unary -x. Hold this table in mind; every trap below is one row of it violating intuition.
Chained Comparison: A Feature That Traps When Misread
The reference defines chaining precisely (comparisons): “if a, b, c, …, y, z are expressions and op1, op2, …, opN are comparison operators, then a op1 b op2 c ... y opN z is equivalent to a op1 b and b op2 c and ... y opN z, except that each expression is evaluated at most once.” And: “all comparison operations in Python have the same priority” and chain “left-to-right.”
The intended, lovely use:
if 0 <= x <= 10: # 0 <= x AND x <= 10, x evaluated once
...The bytecode proves the “evaluated once” claim. For a < b < c (verified on 3.14.5):
LOAD_FAST_LOAD_FAST (a, b)
SWAP 2
COPY 2 # duplicate b so it survives the first compare
COMPARE_OP 2 (<) # a < b
COPY 1
TO_BOOL
POP_JUMP_IF_FALSE -> L1 # short-circuit: if a<b is false, jump
NOT_TAKEN
POP_TOP
LOAD_FAST_BORROW (c)
COMPARE_OP 2 (<) # b < c — note b is reused, NOT reloaded
RETURN_VALUE
L1: SWAP 2; POP_TOP; RETURN_VALUE
Line by line: the compiler loads a and b, then COPY 2 duplicates b on the stack so the second comparison can reuse it without re-evaluating the expression that produced it. After a < b, a POP_JUMP_IF_FALSE short-circuits — if a < b is already False, c is never touched. Only if the first comparison holds does it run b < c against the retained copy of b. This is the entire feature: middle operands evaluated once, short-circuit on the left. If b were next(it) or a function with side effects, chaining and the naive a < b and b < c would behave differently (the naive form evaluates b twice) — a real reason chaining matters beyond aesthetics.
Where chaining traps you
The trap is when a reader does not realize chaining is happening. Three concrete failure scenarios:
1. a < b == c is a chain, not what you think. It means a < b and b == c, not (a < b) == c. Verified surprise:
>>> False == False in [False]
TrueParse it as a chain: (False == False) and (False in [False]) → True and True → True. Most readers expect False == (False in [False]) → False == True → False, or (False == False) in [False] → True in [False] → False. Both “expected” readings give False; the actual chained reading gives True. Verified via AST: the parse is a single Compare node with two ops (==, in) and the operands False, False, [False] — unmistakably a chain.
2. x < y > z is legal but meaningless-looking. The docs note verbatim: “a op1 b op2 c doesn’t imply any kind of comparison between a and c, so that, e.g., x < y > z is perfectly legal (though perhaps not pretty).” It means x < y and y > z — no relationship between x and z is asserted. Code that looks like it’s checking a range may be checking a peak.
3. Range checks: chaining vs in range. 0 <= x <= 10 is the idiomatic inclusive range check and is correct and fast. People sometimes reach for x in range(0, 11) instead — which works for integers but (a) is O(1) only because range.__contains__ is special-cased for ints, becoming O(n) for non-int or stepped ranges, and (b) silently excludes non-integer x (2.5 in range(0, 11) is False). Prefer the chained comparison for range tests; reserve in range for “is this an integer member of this exact sequence.”
is not and not in are single operators
A small but real parsing point: is not and not in are two-word operators, not is (not ...) or not (in ...). They sit on the same comparison rung and chain like any comparison. x is not None is the single identity-negation operator; do not “simplify” it to not x is None (which parses as not (x is None) — same result, but reads worse and invites the precedence question). Use is not / not in directly.
Precedence Traps in Arithmetic and Logic
The bitwise trap: & and | bind tighter than comparison
This is the most damaging one in real code. Because & sits below the comparison row, in plain Python:
>>> x = 6
>>> x & 1 == 0 # parses as (x & 1) == 0 → True, the "expected" resultWait — for plain Python ints, (x & 1) == 0 is exactly what most people want (test the low bit), and the AST confirms & binds tighter than ==, so x & 1 == 0 actually parses as (x & 1) == 0. So where is the bug? The catastrophe lives in NumPy and pandas, where & is overloaded as elementwise logical AND (because the Python keyword and cannot be overloaded and does not work on arrays). There, people write the boolean-mask join:
# INTENDED: rows where a == 0 AND b == 1
mask = df.a == 0 & df.b == 1But & binds tighter than ==, so this parses (AST-verified on 3.14.5) as a chained comparison:
df.a == (0 & df.b) == 1 # df.a == (0 & df.b) AND (0 & df.b) == 1— which is nonsense. In pandas this surfaces as one of the most-Googled tracebacks in the ecosystem:
ValueError: The truth value of a Series is ambiguous.
Use a.empty, a.bool(), a.item(), a.any() or a.all().
The error fires because the chained form forces pandas to evaluate df.a == (0 & df.b) and then and-combine it with the next comparison, and and on a Series has no single truth value. Worse, with the right dtypes the expression sometimes does not raise and instead returns a silently wrong boolean mask — the dangerous case, because the program keeps running on bad data. The fix is mandatory parentheses around each comparison:
mask = (df.a == 0) & (df.b == 1) # correctThis single precedence rule is responsible for an enormous fraction of pandas beginner bugs. The rule to memorize: in array code, always parenthesize each comparison before combining with & | ~. (Note ~ for not, also tighter than comparison, has the same trap.) The dispatch of & to __and__/__rand__ on the array type is operator dispatch; the precedence is independent of which __and__ runs.
not a == b versus not (a == b)
not binds looser than comparison, so (AST-verified):
>>> not a == b # parses as not (a == b)This happens to match intuition — not a == b does mean “a is not equal to b” — but it is worth knowing why: it is not that not somehow reaches across ==; it is that not is lower-precedence, so a == b binds first and not negates the whole comparison. The pitfall is the adjacent case not a in b, which parses as not (a in b) and is better written as the dedicated a not in b. And not a is b → not (a is b) → write a is not b.
a and b or c: the ternary antipattern
Before Python had conditional expressions, people wrote a and b or c as a fake ternary, intending b if a else c. It usually works because and binds tighter than or, so it parses (a and b) or c. The bytecode (verified) shows the short-circuit structure: evaluate a; if falsy, fall through toward c; else evaluate b; if b is truthy return it, else fall to c. The trap: if b is itself falsy, the whole thing returns c even when a was true. True and 0 or 'x' returns 'x', not 0. The correct, unambiguous form is the conditional expression b if a else c — use it always; never the and/or ternary.
The conditional expression binds looser than almost everything
The ternary A if C else B sits near the very top of the loose end of the table — only lambda and := bind looser. This catches people combining it with boolean operators (AST-verified):
>>> x or y if cond else z # parses as (x or y) if cond else zThe or binds tighter than the if/else, so the test branch is x or y — not x or (y if cond else z) as some read it. Likewise a if b else c + d is a if b else (c + d), because + binds tighter than the conditional. When a conditional expression appears next to any other operator, parenthesize the whole ternary: (A if C else B). The mirror-image of the and/or fake-ternary above — here the real ternary is correct, but its loose binding still surprises.
-2 ** 2 is -4: power binds tighter than unary minus
From the reference verbatim: “The power operator binds more tightly than unary operators on its left; it binds less tightly than unary operators on its right … -1**2 results in -1.” So:
>>> -2 ** 2 # -4, i.e. -(2 ** 2), NOT (-2) ** 2 == 4The disassembly of -x ** 2 (using a variable so the constant folder does not pre-compute it) proves the order (verified on 3.14.5):
LOAD_FAST_BORROW (x)
LOAD_SMALL_INT 2
BINARY_OP 8 (**) # x ** 2 happens FIRST
UNARY_NEGATIVE # then negate the result
RETURN_VALUE
BINARY_OP ** executes before UNARY_NEGATIVE: the exponentiation binds the 2 to x, and only the result is negated. (For the literal -2 ** 2, CPython’s constant folder collapses the whole expression to the constant -4 at compile time — LOAD_CONST -4 — but the meaning is identical; the variable form exposes the operation order the folder is honoring.) If you mean “square negative two,” you must write (-2) ** 2.
** is right-associative
>>> 2 ** 3 ** 2 # 512, i.e. 2 ** (3 ** 2) == 2 ** 9, NOT (2 ** 3) ** 2 == 64AST-verified: ** groups right-to-left, matching mathematics (a tower of exponents evaluates top-down). This is the only common right-associative operator in Python, which is exactly why it surprises — every other binary arithmetic operator is left-associative.
flowchart LR subgraph "What you read" A1["x & 1 == 0"] A2["-2 ** 2"] A3["a and b or c"] end subgraph "How Python parses it" B1["(x & 1) == 0<br/>(plain int) /<br/>chained mess in numpy"] B2["-(2 ** 2) = -4"] B3["(a and b) or c"] end A1 --> B1 A2 --> B2 A3 --> B3 style B1 fill:#fdd style B2 fill:#fdd style B3 fill:#fdd
Figure: three expressions and their actual parse. The insight: in every case the grammar is deterministic and documented — the surprise is purely a mismatch with the reader’s mental precedence. The fix for all of them is the same: parentheses make the intended grouping explicit and survive code review.
Failure Modes and Diagnosis
- A pandas/NumPy boolean mask raises “truth value of an array is ambiguous.” You wrote
a == 0 & b == 1without parenthesizing the comparisons;&bound tighter and created a chained comparison. Fix:(a == 0) & (b == 1). - A comparison expression returns a value you can’t explain. Suspect a chain. Run
import ast; ast.dump(ast.parse("your expr", mode="eval").body)— a singleComparenode with multipleopsmeans it chained. - A fake-ternary
a and b or creturnscunexpectedly.bevaluated falsy. Replace withb if a else c. -x ** nhas the wrong sign.**bound tighter than the minus. Parenthesize the base if you meant(-x) ** n.- Unsure of any grouping? Disassemble with
dis.dis("expr")or dump the AST. The bytecode order is ground truth — see The dis Module and Bytecode Disassembly.
The universal cure is explicit parentheses. They cost nothing, never change a correct meaning, and convert every trap above into self-documenting code. PEP 8 and every style guide favor parenthesizing mixed-operator expressions for exactly this reason.
Resolved (2026-06-01)
The opcodes shown are the 3.14.5 set, confirmed live:
dis.dis(compile('1 < x < 10', '<s>', 'eval'))on CPython 3.14.5 emitsLOAD_SMALL_INT,LOAD_NAME,SWAP,COPY,COMPARE_OP,TO_BOOL,POP_JUMP_IF_FALSE,RETURN_VALUE; andLOAD_FAST_BORROWandBINARY_OPare both present inopcode.opmap. The instruction set is not a stable API and is re-specified each minor release, but the grouping and evaluation order — the actual gotcha — is fixed by the language grammar regardless of opcode renaming.
See Also
- Operator Overloading and the Numeric Protocols — how
<,&,**dispatch to__lt__/__and__/__pow__and the reflected/NotImplementedfallback; precedence decides grouping, this decides which method runs. - Python Bytecode Instruction Set — the
COMPARE_OP,BINARY_OP,COPY,POP_JUMP_IF_FALSEopcodes seen above. - The dis Module and Bytecode Disassembly — how to get the bytecode to settle a precedence argument yourself.
- Truthiness and Falsy Values — why
a and b or cand short-circuit operators return operands, notbool, and how falsybbreaks the fake ternary. - Python Internals MOC §16 “Common Gotchas”.