Chained comparison semantics
Python treats a < b < c as (a < b) and (b < c), with two important differences from writing it out by hand: b is evaluated only once, and evaluation short-circuits, so if the first comparison is False the third operand is never evaluated. It is not parsed as (a < b) < c as in C-like languages, where you would compare a boolean to c. Any mix of comparison operators can be chained, including ==, !=, in, is and not in, and the chain can be arbitrarily long.
Why this matters: single evaluation is semantically important when the middle operand is a function call, property or expensive expression, so 0 < compute() < limit calls compute() once. It also makes range checks readable, which is why I always prefer 0 <= i < n over i >= 0 and i < n. Bytecode-wise, CPython duplicates the middle value on the stack and jumps out early on a false result; you cannot reproduce that with a normal function because function arguments are all evaluated eagerly.
Common mistakes: expecting a != b != c to mean 'all three distinct' (it only compares adjacent pairs, so a == c is allowed); writing exotic chains such as x < y > z that are legal but confusing; and using chained comparisons with numpy arrays or pandas Series, where the implicit 'and' calls bool() on an array and raises 'truth value of an array is ambiguous'. For arrays use (a > 0) & (a < 10) or np.logical_and, and for SQLAlchemy columns use .between() or & for the same reason. These libraries cannot override 'and', which is what the chain desugars to.
Trade-off: chained comparisons are a readability win for numeric ranges, but I avoid chains mixing different operator kinds (a == b in c) because reviewers must mentally expand them. This behaviour is stable across all Python 3 versions.
0-2 years experience
2-5 years experience
5-8 years experience
8+ years experience