skip to content

What does `a < b < c` mean in Python, and how many times is `b` evaluated?

level: juniorimportance: must knowfreq 75%

answer

  1. Two comparisons, not one boolean
  2. The middle operand is special
  3. It is evaluated exactly once
  4. Short-circuits exactly like `and`
  5. Means `a<b` and `b<c`

basics

~10 s

Python expands a < b < c into a < b and b < c, but evaluates the middle expression exactly once. If the first comparison is false, the second comparison is never performed.

solid answer

~50 s

A chained comparison is not `(a < b) < c`. Python's grammar allows a run of comparison operators, and `a < b < c` is defined to mean `a < b and b < c` with one important refinement: the middle expression is evaluated **only once** and its value reused for both comparisons. The chain also short-circuits like `and` does, so if `a < b` is false, `b < c` is never evaluated at all, and any call or side effect on the right-hand side never happens. That single evaluation is why you cannot mechanically rewrite `f() < g() < h()` as `f() < g() and g() < h()` when `g()` is expensive, stateful, or returns a fresh object each call. In practice this makes range checks read naturally: `0 <= i < len(items)` or `lo < value <= hi`.

code

python · 7 lines
python
def probe(label, value):
    print("evaluating", label)
    return value


print(1 < probe("b", 5) < 10)
print(3 > probe("b", 5) > probe("c", 1))

go deeper

for a junior

Be ready to state that a < b < c means a < b and b < c and to use it for bounds checks like 0 <= i < len(items). Knowing it is not (a < b) < c is the point of the question.

for a middle

Explain the mechanics: the middle expression is evaluated exactly once and reused, and the chain short-circuits like and. Show why the naive and rewrite calls the middle twice and is therefore not a safe refactor.

for a senior

Demonstrate you spot the hazard in review - a chain whose middle operand is a call with side effects, or a hand-expansion that silently doubles that call. Be able to reach for dis.dis to settle the argument with evidence rather than assertion.

for a principal

Own the style guidance: chains make interval tests readable but hurt when operands are long expressions or the chain exceeds three operands. Decide where the team draws that line and encode it in review conventions rather than re-litigating per pull request.

## The grammar, not a special case for `<` Python's grammar defines a comparison as one expression followed by *any number* of `(comparison-operator, expression)` pairs. So `a < b < c` is a single comparison expression with two operators, not two nested expressions. The language reference gives the meaning directly: an expression of the form `a op1 b op2 c ... y opN z` is equivalent to `a op1 b and b op2 c and ... and y opN z`, **except that each expression is evaluated at most once**. That "at most once" clause is the whole interview question. Two properties follow from it. ## Property one: the middle expression is evaluated exactly once In `a < b < c`, the value of `b` is computed a single time and then used as the right operand of the first comparison and the left operand of the second. This matters the moment the middle expression is anything but a plain name: ```python def probe(label, value): print("evaluating", label) return value 1 < probe("b", 5) < 10 # prints "evaluating b" exactly once, then yields True ``` If Python re-evaluated the middle, that snippet would print twice. It does not. The consequence is that the "obvious" manual expansion is **not** an equivalent refactor: ```python f() < g() < h() # calls f, g, h at most once each f() < g() and g() < h() # calls g twice - different behaviour ``` If `g()` increments a counter, pops from a queue, advances an iterator, or costs a network round trip, those two lines do genuinely different things. When you need to break a chain apart, bind the middle to a local name first, preserving the original left-to-right evaluation order: ```python _a = a() _b = b() _a < _b and _b < c() ``` ## Property two: the chain short-circuits Because the desugaring is an `and`, evaluation stops at the first false comparison. In `5 < 3 < 1/0` the sub-expression `1/0` is never evaluated and the whole expression is simply `False` - no `ZeroDivisionError`. Operands to the right of a failed comparison are never computed, which is occasionally load-bearing: `x is not None and 0 < x` style guards can often be written as a chain where the cheap test comes first. Evaluation order within the chain is strictly left to right: operands are evaluated in source order, and each comparison is performed as soon as both of its operands exist. ## What the compiler actually emits You can watch the mechanism directly. `dis.dis("a < b < c")` shows the compiler evaluating `a` and `b`, duplicating the middle value on the stack, performing the first comparison, taking a conditional jump that abandons the rest of the chain when the result is false, and otherwise continuing with the saved copy of `b` as the left operand of the second comparison. There is no temporary variable in your code and no re-execution of the middle expression - the value simply stays on the stack. ## The contrast that makes it memorable In C-family languages, `a < b < c` parses as `(a < b) < c`: the first comparison yields 0 or 1 and *that* is compared with `c`. The expression compiles and almost never means what the author intended. Python deliberately rejected that reading, which is why the chained form is idiomatic here and a bug there. Note that Python will happily evaluate the C-style meaning if you write the parentheses yourself - `(1 < 2) < 3` is `True < 3`, which is `1 < 3`, which is `True` - because `bool` is a subclass of `int`. ## Using it well Chaining is at its best for interval tests, where it puts the bounded value visually between its bounds: * `0 <= index < len(items)` - the canonical bounds check. * `"a" <= char <= "z"` - a range test over single-character strings. * `start <= timestamp < end` - a half-open window, with the asymmetry visible at a glance. It reads poorly when the operands are long expressions, because the middle one appears once but is conceptually used twice; bind a name in that case. Chains longer than three operands (`a < b < c < d`) are legal but rare, and reviewers generally find them harder to verify than the explicit `and` form. Finally, note that a chain does not require the same operator throughout - mixing is legal and has a surprise or two in it, which is a separate topic from the evaluation semantics covered here.

  • If `a < b` is false, is the right-hand operand of the second comparison evaluated?
    No. The chain behaves like `and`, so evaluation stops at the first false comparison and everything to its right is skipped. `5 < 3 < 1/0` evaluates to `False` rather than raising `ZeroDivisionError`, because the division is never performed. Any function call sitting in that position is simply never made.
  • How would you demonstrate to a colleague that the middle expression runs only once?
    Put an observable side effect in it - a function that prints or appends to a list - and evaluate the chain; the effect happens once. For a stronger demonstration, disassemble the expression with `dis.dis` and point at the stack copy of the middle value and the conditional jump, which show the reuse and the short-circuit with no re-execution.
  • Why is `f() < g() < h()` not equivalent to `f() < g() and g() < h()`?
    The second form calls `g()` twice. If `g` is expensive, mutates state, consumes an iterator, or returns a new object each call, the two lines behave differently - and the second may even give a different answer. The faithful expansion binds the middle to a temporary first, keeping the original left-to-right evaluation order.
  • What does `a < b < c` mean in C-style languages, and why does it matter here?
    There it parses as `(a < b) < c`, comparing a boolean result against `c` - it compiles but almost never expresses the intended range test. Python chose chaining precisely to make the natural reading correct. If you genuinely want the C meaning in Python you must write the parentheses yourself, and `bool` being an `int` subclass makes it evaluate.

Think of the middle value as a baton in a relay: it is fetched once, handed from the first comparison to the second, and if the first runner falls the second never leaves the blocks.

saying these in an interview costs you the question

  • Says `a < b < c` compares the result of `a < b` against `c`
  • Claims the middle expression is evaluated twice
  • Says chained comparisons never short-circuit
  • Rewrites `f() < g() < h()` with `and`, calling `g()` twice
  • Thinks chaining is a special rule only for `<` and `>`
  • Believes a chain is just syntax sugar with no evaluation differences

context