How Python Implements Chained Comparison Operators
Most modern programming languages evaluate chained comparisons strictly through operator precedence and left-to-right associativity. In languages like C, C++, or Java, an expression such as a < b < c does not mean “is b strictly between a and c”; instead, it evaluates (a < b) to an integer boolean (0 or 1), and then compares that resulting integer against c.
Python, however, treats chained comparisons mathematically: a < b < c translates conceptually to (a < b) and (b < c). More importantly, Python evaluates the intermediate expression b only once and short-circuits the rest of the chain if any comparison evaluates to False.
Understanding how CPython achieves this requires peering into CPython’s stack-based virtual machine and examining the exact bytecode instructions generated by the Python compiler.
The Behavioral Divergence: Python vs. C
To observe the semantic differences, consider two classic expressions executed in both C and Python.
Example 1: Negative Number Chaining
-3 < -2 < -1
- Python’s interpretation:
(−3<−2)∧(−2<−1)⟹True∧True⟹True
- C’s interpretation:
(−3<−2)<−1
-3 < -2 evaluates to integer 1 (true).
- The expression reduces to
1 < -1.
1 < -1 evaluates to integer 0 (false).
Example 2: Equality and Relational Chaining
3 > 2 == 1
- Python’s interpretation:
(3>2)∧(2==1)⟹True∧False⟹False
- C’s interpretation:
(3>2)==1
3 > 2 evaluates to integer 1.
- The expression reduces to
1 == 1.
1 == 1 evaluates to integer 1 (true).
| Expression | Python Output | C Output | Underlying Reason |
|---|
-3 < -2 < -1 | True | 0 (False) | C compares intermediate boolean 1 against -1 |
3 > 2 == 1 | False | 1 (True) | C compares intermediate boolean 1 against 1 |
Inspecting Bytecode with Python’s dis Module
CPython is a stack-based virtual machine. It does not use registers; instead, operands are pushed onto an evaluation stack, and operations pop operands off the stack and push the result back on.
We can inspect the bytecode generated by CPython using the standard library’s dis (disassembler) module:
import dis
dis.dis("1 < 2 < 3")
The disassembly produces an instruction sequence similar to this:
1 0 LOAD_CONST 0 (1)
2 LOAD_CONST 1 (2)
4 DUP_TOP
6 ROT_THREE
8 COMPARE_OP 0 (<)
10 JUMP_IF_FALSE_OR_POP 18
12 LOAD_CONST 2 (3)
14 COMPARE_OP 0 (<)
16 RETURN_VALUE
>> 18 ROT_TWO
20 POP_TOP
22 RETURN_VALUE
Notice that the compiler generates instructions specifically designed to handle the stack lifecycle of the middle operand: DUP_TOP, ROT_THREE, and JUMP_IF_FALSE_OR_POP.
Step-by-Step Stack Execution: Evaluating 1 < 2 < 3
Let’s trace how the evaluation stack evolves when evaluating an expression that succeeds through the entire chain.
sequenceDiagram
participant Stack as CPython Evaluation Stack
Note over Stack: Initial state: []
Note over Stack: LOAD_CONST 1 -> [1]
Note over Stack: LOAD_CONST 2 -> [1, 2]
Note over Stack: DUP_TOP -> [1, 2, 2]
Note over Stack: ROT_THREE -> [2, 1, 2]
Note over Stack: COMPARE_OP (<) -> [2, True]
Note over Stack: JUMP_IF_FALSE_OR_POP -> [2]
Note over Stack: LOAD_CONST 3 -> [2, 3]
Note over Stack: COMPARE_OP (<) -> [True]
Note over Stack: RETURN_VALUE -> Returns True
1. LOAD_CONST 1 and LOAD_CONST 2
The constants 1 and 2 are pushed onto the stack.
- Stack:
[1, 2] (top is right)
2. DUP_TOP
Because 2 is the middle operand, it must participate in two distinct comparisons: 1 < 2 and 2 < 3. CPython duplicates the top element.
3. ROT_THREE
The virtual machine rotates the top three elements of the stack. The top element is lifted and moved down two slots:
- Before rotation:
[1, 2, 2]
- After rotation:
[2, 1, 2]
Now, the two operands for the first comparison (1 and 2) are at the top of the stack, while the duplicated 2 is preserved beneath them for the upcoming comparison.
4. COMPARE_OP (<)
CPython pops the top two elements (1 and 2), executes 1 < 2, and pushes the boolean result (True) onto the stack.
5. JUMP_IF_FALSE_OR_POP 18
This instruction checks the top of the stack:
- If the value is
False, it jumps directly to offset 18 (short-circuiting the remaining expression) without popping False.
- If the value is
True, it pops True from the stack and continues to the next instruction.
Since the top is True, it pops True.
6. LOAD_CONST 3
The final operand 3 is loaded.
7. COMPARE_OP (<) and RETURN_VALUE
The machine pops 2 and 3, evaluates 2 < 3 (True), and pushes True.
Finally, RETURN_VALUE pops True and delivers it to the caller.
Short-Circuiting in Action: Evaluating 6 > 7 > 8
Chained comparisons must not perform subsequent comparisons if an earlier one fails. Let’s trace 6 > 7 > 8 where the first condition fails.
1 0 LOAD_CONST 0 (6)
2 LOAD_CONST 1 (7)
4 DUP_TOP
6 ROT_THREE
8 COMPARE_OP 4 (>)
10 JUMP_IF_FALSE_OR_POP 18
12 LOAD_CONST 2 (8)
14 COMPARE_OP 4 (>)
16 RETURN_VALUE
>> 18 ROT_TWO
20 POP_TOP
22 RETURN_VALUE
Execution Flow:
- Push Operands:
LOAD_CONST 6 → [6]
LOAD_CONST 7 → [6, 7]
- Duplicate & Rotate:
DUP_TOP → [6, 7, 7]
ROT_THREE → [7, 6, 7]
- First Comparison:
COMPARE_OP (>) evaluates 6 > 7.
- The result is
False.
- Stack:
[7, False]
- Short-Circuit via
JUMP_IF_FALSE_OR_POP 18:
- The top of the stack is
False.
- Because it is falsy, the VM does not pop it. Instead, it immediately branches to offset
18.
- The instructions at offsets
12, 14, and 16 (loading 8 and testing 7 > 8) are completely skipped.
- Cleanup:
- At offset
18, the stack contains [7, False].
ROT_TWO swaps the top two elements: [False, 7].
POP_TOP discards 7: [False].
RETURN_VALUE returns False.
Because of this jump, any side effects in the skipped operands (such as function calls like 6 > 7 > expensive_function()) are guaranteed never to execute.
The Role of Stack Manipulation Instructions
CPython handles chained comparisons without maintaining high-level AST evaluation states at runtime. Instead, it relies purely on stack primitives:
DUP_TOP: Clones the top stack value so that an intermediate expression can serve as both the right-hand side of operation N and the left-hand side of operation N+1.
ROT_THREE: Shuffles the duplicated value under the two comparison operands, ensuring the stack is ordered properly for binary comparison without needing temporary local variables.
JUMP_IF_FALSE_OR_POP: Conditionally branches while leaving the failure result (False) on the stack to serve as the final return value of the full expression.
ROT_TWO + POP_TOP: Cleans up lingering duplicated operands left on the stack when an early exit occurs.
What Would C-Style Evaluation Look Like in Bytecode?
If Python were designed to evaluate chained comparisons the way C does, the compiler wouldn’t need DUP_TOP, ROT_THREE, or JUMP_IF_FALSE_OR_POP.
Instead, it would simply emit:
LOAD_CONST 6
LOAD_CONST 7
COMPARE_OP (>)
LOAD_CONST 8
COMPARE_OP (>)
RETURN_VALUE
In that hypothetical model:
6 > 7 evaluates to False (0).
- Stack becomes
[False].
8 is pushed: [False, 8].
COMPARE_OP checks 0 > 8, yielding False.
While simpler to compile, this approach breaks intuitive mathematical chaining, where a<b<c inherently implies both inequalities hold simultaneously.
Key Takeaways
- Mathematical Semantics vs. C Associativity: Python treats
a < b < c as (a < b) and (b < c), whereas C treats it as ((a < b) < c) due to strict left-associativity and boolean-to-integer conversions.
- Single Evaluation of Intermediates: In Python, the middle operand is evaluated exactly once, eliminating redundant computations and avoiding duplicate side effects.
- Efficient Stack Mechanics: CPython utilizes
DUP_TOP and ROT_THREE to duplicate and position the shared operand without allocating stack frame local variables.
- Instruction-Level Short-Circuiting:
JUMP_IF_FALSE_OR_POP provides out-of-the-box short-circuit evaluation, bypassing subsequent comparisons and constant loads as soon as any condition evaluates to False.