skip to content

Why is `x in range(10**9)` O(1) for an int but O(n) for a float?

level: middleimportance: must knowfreq 48%

answer

  1. Two code paths, one operator
  2. The fast path is type-guarded
  3. Bounds check plus a remainder test
  4. Anything else falls back to a scan
  5. An int subclass misses the fast path too

basics

~10 s

For 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 lines
python
import 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context