Why is `x in range(10**9)` O(1) for an int but O(n) for a float?
answer
- Two code paths, one operator
- The fast path is type-guarded
- Bounds check plus a remainder test
- Anything else falls back to a scan
- An int subclass misses the fast path too
basics
~10 sFor an exact int, range.contains answers with arithmetic: bounds check plus a remainder test against the step. Any other type, including a float, falls back to iterating the range and comparing values.
solid answer
~40 s`range.__contains__` has two paths. When the probe is exactly an `int` (or a `bool`), CPython decides membership in closed form: is the value inside the interval described by `start` and `stop`, and is `(x - start) % step == 0`? That is constant time regardless of the range's length. Any other object — a `float`, a `str`, even a user-defined subclass of `int` — misses that guarded fast path and falls back to the generic sequence search, which walks the range comparing each produced value with `==`. So `9999999 in range(10**7)` is microseconds while `9999999.0 in range(10**7)` takes about a tenth of a second, and on a 10**12-element range the float form never finishes. Normalize the probe with `int(x)` after checking `x == int(x)`.
code
python · 10 linesimport time
def timed(expr_result_fn, label):
t = time.perf_counter()
result = expr_result_fn()
print(label, result, round(time.perf_counter() - t, 4))
r = range(10**7)
timed(lambda: 9999999 in r, "int ")
timed(lambda: 9999999.0 in r, "float")go deeper
Recall that testing an integer against a range is cheap and does not iterate. Knowing the exception exists — that a non-int probe is slow — is already enough at this level.
Explain both paths concretely: the bounds check plus (x - start) % step == 0 for an exact int, and the fallback element-by-element comparison for anything else. Be able to say why 5.0 still returns True but slowly.
Demonstrate that you would catch this in review. Membership probes typed as float or as an int subclass inside a hot loop are a real production stall; show the normalization fix and how you would spot it in a profile.
Frame the general principle: operator syntax hides algorithmic cost, so container-plus-probe types are part of the performance contract. Decide when a codebase should forbid duck-typed numeric probes at hot boundaries rather than fixing them one by one.
### Two completely different code paths behind one operator `x in r`, where `r` is a `range` object, calls `range.__contains__`. CPython implements that method with a deliberate fork: * If `x` is *exactly* an `int` (or a `bool`, which is an `int` subclass CPython checks for explicitly), the answer is computed with arithmetic and no iteration at all. * For any other object, the operator falls back to the generic sequence search: iterate the range from the beginning, comparing each produced value with `==`, and stop at the first match or at the end. The first path is O(1) — a handful of integer operations regardless of how long the range is. The second is O(n) in the length of the range, and on a long range that is not "a bit slower", it is a hang. ### The arithmetic the fast path does For `x in range(start, stop, step)` with an integer `x`, membership is decidable in closed form. The value must lie inside the half-open interval the range covers, on the correct side of `start` for the sign of `step`, and it must be reachable from `start` by a whole number of steps — that is, `(x - start) % step == 0`. Three comparisons and one remainder, and the answer is known: ```pycon >>> 7 in range(0, 10**18, 7) True >>> 8 in range(0, 10**18, 7) False ``` Both answers come back instantly on a range with 10^17 elements, because nothing is ever produced. ### Why a float cannot take that path The fast path is guarded by an exact type check, not by "is this number-like". A `float`, a `str`, or even a user-defined subclass of `int` all miss it and take the linear scan. Numerically the float answer is often the same — `5.0 == 5` is `True`, so `5.0 in range(10)` is `True` — but the *route* to that answer walked five elements, and on a large range it walks a very long way: ```pycon >>> 9999999.0 in range(10**7) # true, after ~10 million comparisons True >>> 9999999 in range(10**7) # true, immediately True ``` On a typical machine the first line takes on the order of a tenth of a second; the second is microseconds. Push the range to 10^12 and the float form is no longer a slow answer, it is an unfinished one. The same trap catches a value that is *absent*: `5.5 in range(10**7)` must exhaust the whole range before it can say `False`. The int-subclass case is the nastiest version, because the code looks correct. A domain wrapper such as `class RowOffset(int)` reads exactly like an int and compares equal to one, but membership tests against a long range fall off the fast path and quietly become linear. ### Fixing it The fix is one call: normalize the probe to a plain `int` before the test, and be explicit about what "is this an integer" means for your data. ```python def in_progression(x, r): if x != int(x): # a non-integral float is never in a range return False return int(x) in r ``` `int(x)` on a value that is not integral would silently truncate, so the `x != int(x)` guard comes first. For an `int` subclass, `int(x)` returns a plain `int` and restores the fast path. ### The general lesson this question is really testing Containment on a Python sequence is not one algorithm. `in` on a `list` is always a linear scan; `in` on a `set` or `dict` is a hash lookup; `in` on a `str` is a substring search; `in` on a `range` is O(1) for an exact `int` and linear for anything else. The operator hides which one you get, so the cost of `in` is a property of the *container and the probe type together*, never of the syntax. An interviewer asking this usually wants two things: that you know `range.__contains__` is special-cased rather than a scan, and that you know the special case is narrow. The second half is what separates a memorized fact from an understanding you can act on when a membership test in a hot loop turns out to be the bottleneck. ### Version note The arithmetic fast path for integer membership in a `range` has been in CPython since Python 3.2, and the behaviour is unchanged through 3.14. There is no version in which the float form was fast.
- Does `True in range(3)` take the fast path?Yes. `bool` is checked explicitly alongside the exact-`int` check, so `True in range(3)` is answered arithmetically and returns `True` because `True` equals 1. It is one of the few subclass-ish cases that does not degrade to a scan.
- What about a domain wrapper like `class RowOffset(int)`?That misses the fast path. The guard is an exact type check, not an `isinstance` check, so a subclass of `int` falls through to the linear scan even though it compares equal to a plain `int`. Convert with `int(x)` at the boundary if such values are used as membership probes against long ranges.
- How does the cost of `in` compare across list, set and range?`in` on a `list` is always a linear scan; on a `set` or `dict` it is an average O(1) hash lookup; on a `str` it is a substring search; on a `range` it is O(1) for an exact `int` and linear otherwise. The operator hides which algorithm you get, so cost is a property of the container and the probe together.
saying these in an interview costs you the question
- Says `in` is always a linear scan on any sequence
- Thinks a range caches its values after the first test
- Assumes any numeric type gets the O(1) path
- Claims a range does a binary search internally
- Believes a float probe raises TypeError instead of scanning