Python Comparison Operators: Syntax and Chaining
python comparison operators: A practical reference to Python comparison operators: syntax, chained comparisons, equality vs identity, type behavior, and common edge ca...
Python comparison operators evaluate to a boolean and drive control flow in if statements, while loops, comprehensions, and sorting logic. The six operators — <, <=, >, >=, ==, and != — look simple, but their behavior depends on how Python dispatches to object methods, how chained expressions are evaluated, and whether you are comparing values or identities.
The Six Operators and What They Return
Each comparison operator takes two operands and returns True or False:
print(3 < 5) # True print(3 <= 3) # True print(5 > 3) # True print(5 >= 6) # False print(3 == 3) # True print(3 != 4) # True
The operators work on any objects that implement the corresponding comparison protocol. Numbers compare by numeric value, strings compare lexicographically, and lists and tuples compare element by element. When you write a == b, Python first tries a.__eq__(b), and if that returns NotImplemented, it falls back to b.__eq__(a). The same dispatch mechanism applies to the ordering operators through __lt__, __le__, __gt__, and __ge__.
This dispatch matters when you define custom classes. A class that implements __eq__ and __lt__ gains full comparison support because Python derives __le__, __gt__, and __ge__ from those two primitives in most cases.
Chained Comparisons Evaluate Left to Right
Python allows you to chain comparison operators into a single expression:
age = 25 if 18 <= age < 65: print("working age")
The expression 18 <= age < 65 is equivalent to 18 <= age and age < 65, with one important difference: the middle operand age is evaluated only once. In the explicit and form, age would be evaluated twice if it were a function call or property access. For a simple variable this makes no difference, but for an expression with side effects it does:
def get_value(): print("evaluated") return 30 result = 10 < get_value() < 50
Here get_value() runs once. Writing 10 < get_value() and get_value() < 50 would run it twice, which is both slower and potentially incorrect if the function is not idempotent.
Chains can be longer than two comparisons, and each comparison is evaluated in order. If any comparison fails, the remaining ones are not evaluated, which is the same short-circuit behavior as and.
Equality vs Identity
The most common source of confusion is the difference between == and is. The == operator compares values using __eq__, while is compares object identity — whether two names refer to the same object in memory.
a = [1, 2, 3] b = [1, 2, 3] c = a print(a == b) # True, same values print(a is b) # False, different objects print(a is c) # True, same object
For small integers and interned strings, is can return True for equal values because CPython caches small integers and interns some strings. Relying on that behavior is fragile. Use is only when you specifically need identity, such as checking x is None or comparing against a singleton. For every value comparison, use ==.
The != operator is the negation of == in the sense that a != b returns not (a == b) unless a class overrides __ne__ explicitly. If a class defines __eq__ but not __ne__, Python derives __ne__ from __eq__, so the two stay consistent.
Comparing Different Types
In Python 3, comparing incompatible types raises TypeError instead of returning an arbitrary ordering:
try: result = 3 < "hello" except TypeError as e: print(e) # '<' not supported between instances of 'int' and 'str'
This is a deliberate change from Python 2, where such comparisons produced a consistent but meaningless ordering. The rule is not absolute: numeric types such as int and float compare cleanly, and None compares with any object using == (returning False unless the other side is also None), but ordering operators with None raise TypeError.
When you need to sort a list that may contain mixed types, you must normalize the values first or provide a key function. Relying on the TypeError to surface the problem is usually better than silently producing a wrong order.
Runtime Cost and Short-Circuit Behavior
Each comparison operator call involves attribute lookup and a method dispatch. For built-in types this is fast, but for custom classes the cost of __eq__ or __lt__ is whatever you implement. If a comparison method performs expensive work — for example, comparing two large data structures field by field — the cost is paid on every comparison, and sorting a list of such objects multiplies that cost by the number of comparisons the sort algorithm performs.
Chained comparisons short-circuit: once a comparison fails, the rest of the chain is skipped. This is useful when the later comparisons are expensive or have side effects. Placing the cheapest or most likely-to-fail comparison first in a chain reduces the average cost.
# If is_valid() is expensive, check the cheap condition first if len(items) > 0 and items[0].is_valid(): ...
The same principle applies to chained comparisons. Order the operands so that the comparison most likely to fail comes first.
Common Edge Cases
Floating-point NaN breaks equality in a way that surprises many developers:
import math nan = float("nan") print(nan == nan) # False print(nan != nan) # True
This follows the IEEE 754 standard, where NaN is not equal to itself. If you need to check for NaN, use math.isnan(nan) rather than comparing with ==.
The == operator on NumPy arrays returns an element-wise boolean array rather than a single boolean. Using it directly in an if statement raises ValueError because the truth value of an array is ambiguous. Use np.array_equal(a, b) or (a == b).all() instead. This is a common production bug when code moves from plain Python lists to NumPy arrays.
Comparing floats for exact equality is also fragile because of binary representation. Two values that are mathematically equal may differ in their least significant bits. For monetary or measurement comparisons, use math.isclose(a, b, rel_tol=..., abs_tol=...) instead of ==.
Overriding Comparison Operators in Custom Classes
When you implement comparison in a class, define __eq__ and __lt__ at minimum. Python fills in the remaining ordering operators from those two in most cases, but you can override __le__ and __gt__ if your class has a more efficient way to compute them. For a class that wraps a single numeric value:
class Temperature: def __init__(self, celsius): self.celsius = celsius def __eq__(self, other): if isinstance(other, Temperature): return self.celsius == other.celsius return NotImplemented def __lt__(self, other): if isinstance(other, Temperature): return self.celsius < other.celsius return NotImplemented
Returning NotImplemented for unsupported types tells Python to try the reflected operation on the other operand, and if that also fails, raise TypeError. This is the correct pattern; raising TypeError directly inside the method prevents Python from attempting the reflected operation and breaks comparisons with compatible types defined elsewhere.
One subtlety: if you define __eq__ in a class, you should also define __hash__, because Python sets __hash__ to None when __eq__ is defined without it. That makes the class unhashable and unusable in sets or as dictionary keys. If the objects are mutable, leaving them unhashable is often the right call; if they are immutable value objects, implement __hash__ based on the same fields used by __eq__.