skip to content

Why should __lt__ or __eq__ return NotImplemented instead of False for an unsupported type?

level: seniorimportance: should knowfreq 40%

answer

  1. One operand may decline to answer
  2. Python then asks the other side
  3. It is returned, not raised
  4. The reflected operator gets its turn
  5. Ordering ends in TypeError, equality in identity

basics

~20 s

NotImplemented means your type declines to answer, so Python tries the other operand's reflected method and finally raises TypeError for ordering, or falls back to identity for ==. Returning False claims a real verdict and blocks the other type from cooperating.

solid answer

~50 s

Rich comparison is a two-sided protocol. For `a < b` Python calls `a.__lt__(b)`; if that returns `NotImplemented` it tries the reflected `b.__gt__(a)`, and if that also declines, `<` raises `TypeError` while `==` falls back to identity comparison. So `NotImplemented` is the way one type says *not my call*. Returning `False` instead asserts a real answer: the other operand's method is never consulted, a unit-wrapper or subclass that knows how to compare with your type is silently ignored, and `==` reports two genuinely equal objects as different. It also breaks `functools.total_ordering`, whose derived operators propagate `NotImplemented`. Note it is the singleton `NotImplemented`, not the exception `NotImplementedError`, and you must **return** it rather than raise it. Since Python 3.12, using `NotImplemented` in a boolean context raises `TypeError`, which usefully catches code that calls a dunder directly and tests the result.

code

python · 31 lines
python
class Meters:
    def __init__(self, value):
        self.value = value

    def __eq__(self, other):
        if not isinstance(other, Meters):
            return NotImplemented
        return self.value == other.value

    def __lt__(self, other):
        if not isinstance(other, Meters):
            return NotImplemented
        return self.value < other.value


class Feet:
    def __init__(self, value):
        self.value = value

    def __eq__(self, other):
        if isinstance(other, Meters):
            return abs(self.value * 0.3048 - other.value) < 1e-9
        return NotImplemented


print(Meters(1) == Feet(1 / 0.3048))
print(Meters(1) == 5)
try:
    Meters(1) < 5
except TypeError as exc:
    print("TypeError:", exc)

go deeper

for a junior

Recall that comparison methods can say they do not handle a type by returning NotImplemented, and that this is a value you return, never an exception you raise.

for a middle

Explain the negotiation: left operand first, reflected method second, then TypeError for ordering or identity fallback for equality. Write the isinstance guard that returns NotImplemented as a matter of habit.

for a senior

Diagnose the failure mode in a real system: equal values comparing unequal, deduplication keeping duplicates, orderings that shift with input order. Know the subclass-first rule and why a hard False in a base class defeats it.

for a principal

Own the convention across a codebase, especially where two teams model the same quantity differently. Decide whether types cooperate through declined comparisons and a shared conversion, or refuse each other outright, and make the boundary explicit rather than emergent.

## The protocol, step by step When Python evaluates `a < b` it does not simply call one method. It runs a small negotiation: 1. If `type(b)` is a **proper subclass** of `type(a)` and overrides the reflected method, Python tries `b.__gt__(a)` first — the subclass gets the first word about its own type. 2. Otherwise it calls `a.__lt__(b)`. 3. If that returns `NotImplemented`, it tries the reflected operation, `b.__gt__(a)`. 4. If that also returns `NotImplemented`, the operator fails: `<`, `<=`, `>`, `>=` raise `TypeError: '<' not supported between instances of ...`, while `==` and `!=` fall back to identity (`is` / `is not`) and return a `bool`. `NotImplemented` is a built-in singleton whose only job is to signal step 3. It is **returned**, never raised, and it is not `NotImplementedError` — that exception is for abstract methods a subclass must override, and raising it from a comparison turns a routine `sorted()` call into a crash with a misleading message. ## What `return False` actually costs `False` is a real answer. It ends the negotiation at step 2, so: **Cooperation is lost.** In an ingest pipeline where one module models a severity as a small wrapper class and another passes a plain integer or a unit wrapper, the second type may know perfectly well how to compare against the first. Returning `NotImplemented` lets Python ask it. Returning `False` guarantees it is never asked, and the two representations of the same value compare unequal forever. **Subclasses lose their override.** Step 1's subclass rule only helps if the base class then declines; a base whose `__eq__` hard-returns `False` short-circuits the very case the rule exists for. **Errors turn into wrong answers.** `record < 5` should raise `TypeError` and be fixed. If `__lt__` returns `False` for anything it does not recognise, that comparison quietly answers *no*, and a sort over a mixed list silently produces an order that depends on the input arrangement rather than the data. The bug surfaces much later, as an ordering nobody can reproduce. **`!=` inherits the damage.** Python 3 derives `__ne__` from `__eq__` by inverting it unless the result is `NotImplemented`. A hard `False` from `__eq__` makes `!=` unconditionally `True` for the foreign type, with the same loss of the other side's opinion. **`functools.total_ordering` stops working.** Its generated operators call the method you wrote and pass `NotImplemented` straight through. If your method never produces it, the derived operators can never decline either. ## The idiom ```python def __lt__(self, other): if not isinstance(other, Meters): return NotImplemented return self.value < other.value ``` The guard is deliberately about *what this method can handle*, not about what the caller ought to pass. Use `isinstance` so subclasses still work, and prefer declining to guessing: a comparison that coerces a string to a number to be helpful is a defect waiting for a production dataset. ## Boolean context Because `NotImplemented` is a singleton object, it used to be truthy — so code that called a dunder directly, `if a.__eq__(b):`, took the true branch even when the comparison had been declined. That was deprecated in Python 3.9 and, since Python 3.12, evaluating `NotImplemented` in a boolean context raises `TypeError: NotImplemented should not be used in a boolean context`. The lesson stands regardless of version: use the operator, `a == b`, and let the interpreter run the negotiation. The operator never yields `NotImplemented` to your code — by the time it returns you have a real value or an exception. ## Diagnosing it in the wild The symptom is rarely a crash. It is two objects that *are* the same value comparing unequal, a `in` test that fails against an equivalent element, a deduplication step that keeps both copies, or a sort whose order changes when the input order changes. When a comparison behaves inexplicably across two types, read both `__eq__` implementations and look for a hard `False`, a bare `return self.x == other.x` that assumes the attribute exists, or an over-broad `try/except AttributeError` swallowing the mismatch. Comparing against a foreign type should end in one of exactly two places: the other type's answer, or `TypeError`.

  • What is the difference between NotImplemented and NotImplementedError here?
    `NotImplemented` is a singleton value you **return** from a binary special method to decline the operation; Python then tries the reflected method. `NotImplementedError` is an exception you **raise** from an abstract method that a subclass is required to implement. Raising the exception from `__eq__` turns an ordinary equality test or `sorted()` call into a crash, and the traceback points at the wrong problem.
  • When does Python try the right operand's method before the left operand's?
    When the right operand's type is a proper subclass of the left operand's type and it overrides the reflected method. The subclass is presumed to know more about comparing with its own base, so it is asked first. This is exactly why a base class must decline rather than hard-return False — otherwise the base can still win in the cases where it did get asked first.
  • Both operands decline a == comparison. What is the result?
    Python falls back to identity: `a == b` becomes `a is b`, so it is `True` only for the very same object and `False` otherwise, and it never raises. Ordering operators have no such fallback — there is no sensible default order between arbitrary objects — so `<`, `<=`, `>` and `>=` raise `TypeError` instead.

NotImplemented is answering "not my call" rather than "no": it passes the question to the other party, while a flat no ends the conversation with a verdict you were not qualified to give.

saying these in an interview costs you the question

  • Returns False from __eq__ for unknown types
  • Raises NotImplementedError from a comparison method
  • Raises NotImplemented instead of returning it
  • Thinks == raises TypeError when both sides decline
  • Tests the result of calling a dunder directly
  • Coerces foreign types inside a comparison to be helpful

context