PYTHON / OPERATORS
Comparison operators and comparison chaining
Compare values with ==, !=, <, <=, >, >= and write chains like 0 <= x < 10, knowing exactly how Python expands and evaluates them.
What you will learn
- Read a < b <= c as a < b and b <= c, with b evaluated exactly once
- Write bounds tests as one chain: 0 <= i < len(items)
- Predict when a chain stops early and skips the remaining calls
- Know that == across unrelated types is False, while < raises TypeError
Understanding Comparison operators and comparison chaining
Python has six comparison operators: == and != for equality, and <, <=, >, >= for ordering. For built-in types they return a real bool. Equality is defined for any pair of objects, so 3 == "3" is simply False rather than an error, but ordering is only defined between types that agree on an order: 3 < 3.0 works because int and float share the numeric ordering, while 3 < "3" raises TypeError because no ordering between int and str exists. Strings, tuples and lists compare element by element from the left, which is why (1, 2) < (1, 3) is True.
Chaining is a grammar feature, not a trick: a < b < c is compiled as a < b and b < c, with one crucial difference from writing that out by hand. The shared middle operand b is evaluated only once, so 1 < f() < 10 calls f exactly one time, whereas 1 < f() and f() < 10 calls it twice. Like and, the chain stops at the first false link, so in 5 < f() < g() the function g is never called when 5 < f() is already False.
The mental model to keep is "links joined by an implicit and", not "a mathematical statement about every pair". That is why a != b != c is True for 1, 2, 1: it only checks a != b and b != c and never compares a with c. Mixed directions are legal too, so row[0] > row[1] < row[2] is a valid valley test. All six operators share one precedence level, sitting below arithmetic and above not/and/or, so x + 1 < y * 2 and not 0 <= x <= 9 both parse the way you would read them aloud.
def probe(name, value):
print("evaluating", name)
return value
print("chained:", 1 < probe("mid", 5) < 10)
print("expanded:", 1 < probe("mid", 5) and probe("mid", 5) < 10)
print("short-circuit:", 5 < probe("a", 3) < probe("b", 99))
print("mixed operators:", 1 < 2 == 2 > 0)A chained comparison a < b < c means a < b and b < c with the middle operand evaluated once, and says nothing directly about a versus c.
Worked examples
Bounds checks in conditions
Chaining expresses two-sided ranges in the order you would say them out loud.
def grade(score):
if not 0 <= score <= 100:
return "invalid"
if 90 <= score <= 100:
return "A"
if 80 <= score < 90:
return "B"
return "C or below"
for s in [105, 95, 85, 42]:
print(s, grade(s))Example explained
Line 10 <= score <= 100 checks both bounds in one expression; score is evaluated once.
Line 2not binds looser than comparison, so not 0 <= score <= 100 negates the whole chain, not just 0 <= score.
Line 380 <= score < 90 mixes <= and < to make the upper bound exclusive, so 90 falls through to the "A" branch only.
Line 4Each call returns as soon as a chain is satisfied, so the ranges are checked top to bottom.
Equality versus ordering across types
== is always defined, but < needs a shared ordering between the two types.
print(3 == 3.0, 3 == "3")
print((1, 2) < (1, 3), (1, 2) < (1, 2, 0))
print("Zoo" < "apple")
try:
3 < "3"
except TypeError as err:
print("TypeError:", err)Example explained
Line 13 == 3.0 is True because int and float compare by numeric value, not by type.
Line 23 == "3" is False rather than an error: unrelated types are simply never equal.
Line 3Tuples compare left to right, and a shorter tuple that is a prefix of a longer one is smaller.
Line 4"Zoo" < "apple" is True because comparison uses code points and uppercase Z (90) precedes lowercase a (97).
A chain is not a statement about all pairs
a != b != c only compares neighbours, so it cannot prove three values are distinct.
a, b, c = 1, 2, 1
print("chain:", a != b != c)
print("a vs c:", a != c)
print("all distinct:", len({a, b, c}) == 3)
row = [3, 1, 2]
print("peak/valley:", row[0] > row[1] < row[2])Example explained
Line 1a != b != c expands to a != b and b != c, both True here, so the chain reports True.
Line 2a != c is False, proving the chain never compared the outer two values.
Line 3len({a, b, c}) == 3 is the correct distinctness test because a set drops duplicates.
Line 4row[0] > row[1] < row[2] chains opposite directions to detect a dip at the middle element.
Important notes
Comparisons bind tighter than not, and, or but looser than arithmetic, so a + 1 < b * 2 and not 0 <= x <= 9 need no parentheses.
Chaining tests the truthiness of each link, so it breaks for objects whose comparisons return non-bool values: 0 < numpy_array < 5 raises ValueError instead of comparing elementwise.
Common mistakes
Writing if x = 5: instead of if x == 5: — Python refuses to compile it and raises SyntaxError with a hint suggesting '==', so the whole file fails to run.
Assuming a != b != c means all three values differ; it only checks neighbouring pairs, so duplicate outer values like 1, 2, 1 slip through as True.
Using == on floats built by arithmetic: 0.1 + 0.2 == 0.3 is False because of binary rounding, so the branch silently never fires; use math.isclose instead.
Try it yourself
Change, predict, then run
Write in_range(x) that returns True only when x is at least 10 and strictly below 100, using a single chained comparison, and print the result for 9, 10, 99 and 100.
Open the Python workspaceCheck your understanding
Given a function f that prints "hi" and returns 3, how many times does "hi" appear while evaluating 0 < f() < 5, and what is the result?
- once, and the result is True
- twice, and the result is True
- once, and the result is False
- twice, and the result is False
Show answer
The chain expands to 0 < f() and f_result < 5 with the middle operand evaluated a single time, so "hi" prints once; both links hold (0 < 3 and 3 < 5), giving True. "twice, True" is tempting if you assume the source text f() is literally duplicated into two calls, which is exactly what chaining avoids.