Why does `10_000_000 in range(0, 20_000_000, 2)` answer instantly in Python?
answer
- Nothing is stored, so nothing is searched
- Two questions: in bounds, on a step
- The shortcut depends on the operand's type
- A float takes the slow road
- Bounds check plus a modulo on the offset
basics
~20 sA range object's membership test special-cases integers: it checks the value lies within the bounds, then that its offset from start divides evenly by the step. No values are visited, so the answer is constant time.
solid answer
~50 s`in` on a `range` does not walk the sequence when the left operand is an `int` (or a `bool`, which is an int). It performs two arithmetic checks: is the value inside the half-open span in the direction the step points, and is `(value - start) % step == 0`? Both are O(1) regardless of how large the span is, which is why membership on a range covering billions of values returns immediately. `range.index()` computes its answer the same way. The catch is that the fast path applies **only to integers**: a float, a string or any other object falls through to the generic sequence scan, which visits values one at a time and is O(n) — on a range of a trillion values that is effectively a hang. Under the hood this is possible only because a range is defined arithmetically rather than stored.
code
pycon · 8 lines>>> 10_000_000 in range(0, 20_000_000, 2)
True
>>> 9_999_999 in range(0, 20_000_000, 2)
False
>>> True in range(3)
True
>>> range(0, 10, 3).index(3)
1go deeper
You will not be marked down for missing this, but know that in works on a range object at all and yields a bool. The interviewer asking it is being curious rather than screening.
Explain the arithmetic test — a bounds check in the step's direction plus a divisibility check on the offset — and be clear that it applies to integers only, so a float falls back to a scan.
Spot the trap in review: a membership test over a very large range with a value that is a float by accident silently becomes a linear walk that can stall a worker with no error to point at.
Have a position on depending on an implementation optimisation for a runtime guarantee. It is documented behaviour here, but a plain bounds comparison states the same intent without the type-dependent cliff.
### Membership on a container versus membership on a rule For most containers, `x in container` means "look through what is stored". A list has to compare against each element until it finds a match or runs out; a set or dict hashes the value and probes a table. Both are answers about *stored* data. A `range` object stores no values at all — only `start`, `stop` and `step`. That means membership can be answered the way a mathematician would answer it: is this number in the arithmetic progression described by those three integers? ### The two checks For an integer `x`, `range.__contains__` asks: 1. **Is `x` inside the half-open span, in the direction the step points?** With a positive step that means `start <= x < stop`; with a negative step, `stop < x <= start`. 2. **Does `x` land on a step boundary?** That is, is `(x - start)` an exact multiple of `step`? If both hold, the answer is `True`. Neither check depends on the number of values in the range, so the cost is constant no matter how enormous the span is: ```pycon >>> 10_000_000 in range(0, 20_000_000, 2) True >>> 9_999_999 in range(0, 20_000_000, 2) False >>> 999_999_999_996 in range(0, 10**12, 3) True ``` The last one describes a third of a trillion values and still answers immediately. `range.index()` works the same way for an integer argument: it verifies membership, then computes the position as `(x - start) // step` rather than searching for it. So `range(0, 10, 3).index(3)` is `1`, computed rather than found. `count()` is likewise `1` or `0`. ### `bool` gets the fast path too `True` and `False` are instances of `int` in Python, with the values `1` and `0`. `True in range(3)` is therefore `True` via the arithmetic path, not a scan. It is a curiosity rather than something to rely on, but it explains an occasional surprise. ### The trap: non-integers fall back to a scan The optimisation is keyed on the operand's type. If the left-hand value is **not** an int, `range` falls back to the generic sequence membership behaviour: iterate the values and compare each with `==`. That is O(n). ```pycon >>> 1.0 in range(5) True ``` That result is correct — `1.0 == 1` — but it was reached by walking the range from the start, not by arithmetic. On `range(5)` nobody notices. On `range(10**12)` a float that is *not* present makes the interpreter compare a trillion integers, and the call never returns in any useful sense. The same is true of a string, a `Decimal`, or any custom object with an `__eq__`. This is the whole practical lesson of the topic: the constant-time behaviour is real, documented and worth knowing, but it is conditional on the operand being an integer, and nothing in the code makes that condition visible at the call site. A value that arrives from JSON parsing, a CSV column or a division is very often a float without anyone intending it. ### Why not just write a comparison? For a contiguous range, `0 <= x < n` says exactly the same thing as `x in range(n)`, is faster still (no method call, no object), and states the intent without depending on an implementation optimisation. Prefer it in hot code and in code where the operand's type is not guaranteed. `x in range(...)` earns its place where the *step* carries meaning — testing membership of a strided set of positions, for example, where the comparison form would need an explicit modulo and be less readable than the range that produced the offsets in the first place. ### Contrast with a list of the same values `x in [0, 2, 4, ...]` and `x in range(0, n, 2)` produce the same answers and have entirely different costs: the list is a linear scan over materialised objects, the range is two arithmetic operations over three stored integers. Converting a range to a list "so membership works" makes the operation strictly worse in both time and memory. If you find yourself doing that, the range was already the better data structure.
- Does membership on a range object stay constant time when the value is a float?No. The arithmetic path is selected by the operand's type and applies to ints and bools only. Any other type falls back to the generic sequence scan, comparing values one at a time with `==`. `1.0 in range(5)` is correct but linear, and the same test against a range spanning a trillion values will effectively never return.
- Is x in range(n) a reasonable substitute for 0 <= x < n?It is correct and constant time for integers, but it builds an object, dispatches a method and quietly depends on an implementation optimisation, so the plain comparison is faster and states the intent more directly. The range form earns its place when the step matters — testing membership of a strided set of positions rather than a contiguous span.
- How does range compute the position when you call index() on it?For an integer argument it runs the same membership test, then computes `(value - start) // step` directly instead of searching. So `index()` on a range is constant time rather than linear, and it raises ValueError when the value is not on a step boundary inside the span.
Asking whether a house number is on the even side of a street: you check the parity and the block bounds, rather than walking the street and reading every door.
saying these in an interview costs you the question
- Assumes membership walks the range value by value
- Thinks the check is fast because range caches its values
- Claims a float uses the same constant-time path
- Converts a range to a list so membership works
- Says the range must be materialised to be searched
- Confuses this with membership on a list of the same values