skip to content

Truthiness and Comparisons

What Python treats as true, what `and` and `or` actually return, and how comparisons dispatch to dunder methods on your classes. It surfaces wherever `if x:` was meant to be `if x is not None:`.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

18

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

open as a page

Why does `3 < 'apple'` raise TypeError in Python 3 when Python 2 ordered them?

level: juniorimportance: must knowfreq 60%

basics

~10 s

Python 3 removed the default ordering between unrelated types. The int's less-than returns NotImplemented for a str, the reflected str greater-than returns NotImplemented too, and with no fallback left the interpreter raises TypeError.

open as a page

In Python, how does `if obj:` decide truthiness for an instance of a custom class?

level: juniorimportance: must knowfreq 58%

basics

~20 s

if obj: calls bool(obj), which looks for __bool__ on the object's type. If there is none, Python falls back to __len__, where a length of 0 means false. With neither defined, every instance is true.

open as a page

Why check `if timeout is None:` instead of `if not timeout:` for an optional argument?

level: juniorimportance: must knowfreq 70%

basics

~20 s

if not timeout: is true for 0, 0.0, '' and [] as well as None, so it silently discards values the caller passed on purpose. is None asks only one question: was the argument omitted?

open as a page

Which built-in Python values are falsy inside an `if` statement?

level: juniorimportance: must knowfreq 85%

basics

~20 s

Falsy built-ins are None, False, zero of any numeric type (0, 0.0, 0j) and every empty container: '', b'', [], (), {}, set(), range(0). Everything else, including the string '0' and the list [0], is truthy.

open as a page

Which methods does Python's `in` operator try when testing membership?

level: middleimportance: must knowfreq 55%

basics

~20 s

x in c calls c.__contains__(x) and coerces the result with bool(). If the type defines no __contains__, Python iterates it via __iter__, then falls back to integer indexing through __getitem__. not in negates the same lookup.

open as a page

Which methods must a Python class define so that < and sorted() work on its instances?

level: middleimportance: must knowfreq 55%

basics

~20 s

Define lt: sorted(), min() and max() need nothing else, because they only ever ask whether one element is less than another. For the full set of ordering operators, add eq and decorate the class with functools.total_ordering, or write all four yourself.

open as a page

What does the expression `a or b` evaluate to in Python, and when is `b` skipped?

level: middleimportance: must knowfreq 70%

basics

~10 s

a or b returns one of the operands, not a bool: a itself when a is truthy, otherwise b. The right operand b is evaluated only when a is falsy. and mirrors this exactly.

open as a page

Why does `sorted()` raise TypeError on a 6,800-row batch whose duration field is sometimes None?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Python 3 refuses to order unrelated types: comparing None with an int raises TypeError, because neither defines an ordering against the other. Equality never raises. Fix it with a key function mapping every row onto one comparable type.

open as a page

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

level: seniorimportance: should knowfreq 40%

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.

open as a page

A telemetry collector's `__len__` scans a 2.4 GB buffer; why does `if collector:` trigger that scan?

level: seniorimportance: should knowfreq 34%

basics

~10 s

Because the class defines no __bool__, truth testing falls back to __len__, so the emptiness check pays for an exact count it then compares with zero. Add a cheap __bool__ that answers emptiness directly.

open as a page

In a search-index rebuilder, a module-level `object()` sentinel fails its `is` test inside worker processes - why?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Identity does not survive serialization. Pickling the sentinel to a worker, or deep-copying it, rebuilds a fresh instance, so is compares two different objects. Fix it with a __reduce__ that returns the name, or an enum member.

open as a page

Why does `threshold = config.threshold or 92` ignore a configured 0, and what replaces it?

level: seniorimportance: should knowfreq 55%

basics

~20 s

or cannot distinguish absent from falsy: a configured 0 is falsy, so the default 92 wins. Use an explicit presence test — cfg.threshold if cfg.threshold is not None else 92 — whenever 0 or '' is legal data.

open as a page

Which Python operators can chain, and does `x != y != z` mean all three differ?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

All ten comparison operators chain, including in, not in, is and is not. A chain compares only adjacent pairs, so x != y != z allows x == z - indeed 1 != 2 != 1 is True.

open as a page

What does functools.cmp_to_key do, and why is it slower than a plain key function?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

functools.cmp_to_key wraps a two-argument comparator that returns a negative number, zero or positive into a key object Python can sort with. The comparator then runs on every comparison, roughly n log n times, instead of once per element.

open as a page

What must a Python class's `__bool__` return, and what error do you get otherwise?

level: middleimportance: nice to knowfreq 22%

basics

~10 s

__bool__ must return an actual bool. Returning anything else, even the integer 1, raises TypeError: __bool__ should return bool, returned int. __len__ is looser but must return a non-negative integer.

open as a page

What is Python's `Ellipsis` object, and what is `...` actually used for?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

... is Ellipsis, the single instance of a built-in type with no behaviour of its own. It is truthy and does nothing; code gives it meaning - as a stub body, inside a subscript, or in annotation forms.

open as a page