When is `for i in range(len(items))` justified, and what replaces it otherwise?
answer
- The default loop does not count
- Ask what the number is for
- Rebinding the loop variable changes nothing
- Positions matter for write-back and neighbours
- len() needs __len__; a generator has none
basics
~20 sIterate the sequence directly when you only need its elements. range(len(items)) earns its place when the index itself is the point: writing a value back into items[i], comparing neighbouring positions, or walking a sub-span of positions.
solid answer
~50 sPython's `for` walks the elements themselves, so `for item in items` is the default and needs no index at all. `for i in range(len(items))` is a detour that costs a name lookup and a bounds-checked subscript on every pass and, more importantly, hides what the loop is for. It is genuinely correct in a small set of cases: assigning back into the sequence (`items[i] = ...`), relating position `i` to `i - 1` or `i + 1`, starting or stopping part-way with `range(1, len(items))`, or driving an index into a parallel structure keyed by position. When you need the element *and* its position, `enumerate` gives both. When you only wanted to rebuild the sequence, a comprehension plus a rebind — or a slice assignment `items[:] = [...]` when other references must see the change — removes the loop entirely.
code
python · 10 lineslevels = [3, 0, 7, 0, 2]
for i in range(len(levels)):
if levels[i] == 0:
levels[i] = -1
print(levels) # [3, -1, 7, -1, 2]
# Rebinding the loop variable would not have worked:
for value in levels:
value = 0
print(levels) # unchangedgo deeper
Know that Python's for loop walks the elements themselves, so for item in items is the default. Reach for an index only when you can say out loud what the number is being used for.
Explain the mechanics: the index form adds a name lookup and a bounds-checked subscript per pass, len() is evaluated once before the loop, and the index earns its place mainly for write-back and neighbour comparisons.
Show review judgement: the fix is not always the same one. Sometimes it is a comprehension that rebinds, sometimes a slice assignment so other holders see the change, and sometimes the index is simply correct.
Frame this as a codebase readability standard rather than a rule argued per pull request, and be explicit about what you would let through — the cases where a position is load-bearing and rewriting it would obscure the intent.
### What the two loops actually do `for item in items` asks `items` for an iterator (`__iter__`) and pulls values from it one at a time. The loop never computes a position; the sequence hands it the next element directly. `for i in range(len(items))` builds a `range` of integers `0 .. len(items) - 1`, iterates those, and leaves you to fetch each element yourself with `items[i]`. Every pass therefore does extra work: look up the name `items`, subscript it, bounds-check the index. That is real but modest overhead; it is not the main argument. The main argument is intent. A loop that iterates elements says "do this to each element". A loop that iterates positions says "the position matters here" — and when it does not, the reader has to scan the body to discover that it never did. ### When the index is genuinely load-bearing **Writing back into the sequence.** Rebinding the loop variable does nothing to the container: `for x in levels: x = -1` reassigns a local name and leaves `levels` untouched. To mutate in place you need the position. ```python levels = [3, 0, 7, 0, 2] for i in range(len(levels)): if levels[i] == 0: levels[i] = -1 ``` **Relating a position to its neighbours.** Comparing each element with the one before it needs both indexes, and the range's start is what expresses "skip the first, there is nothing before it": ```python jumps = [i for i in range(1, len(readings)) if readings[i] != readings[i - 1]] ``` **Walking part of the positions.** `range(1, len(items))`, `range(0, len(items), 2)` and `range(len(items) - 1)` all say something precise about which positions participate. Trying to express those with direct iteration means reconstructing the index anyway. **Driving a position into something else.** When a second structure is keyed by position rather than by element, the index is the shared key and there is nothing to rewrite. ### The rewrites, and when each applies * **Only the elements matter** — iterate directly: `for item in items:`. * **The element and its position matter** — use `enumerate(items)`, which yields both and keeps the element in a name rather than behind a subscript. * **You are transforming every element into a new sequence** — use a comprehension and rebind: `items = [f(x) for x in items]`. If other references must observe the change, slice-assign the same list object instead: `items[:] = [f(x) for x in items]`. * **You are searching, filtering or aggregating** — the built-ins (`any`, `all`, `sum`, `max`, `min`) usually remove the loop altogether. ### The failure mode people forget `len()` requires the object to define `__len__`. Generator objects and most lazily-produced iterators do not, so `range(len(source))` raises `TypeError: object of type 'generator' has no len()`. Materialising it with `list()` to make the index loop work is exactly the wrong reflex: it defeats the laziness that made the source a generator in the first place. If you truly need positions over a lazy source, `enumerate` works on any iterable and costs nothing. Subscripting has the same precondition: `items[i]` needs `__getitem__`, so an index loop silently assumes a *sequence*, not merely an iterable. Direct iteration works on both. ### The performance question, honestly The index form is slower per pass — an extra global or local lookup, a subscript, a bounds check — but the difference is small enough that it is never the reason to change the code. It is a readability and correctness argument. The two things worth knowing are that `len(items)` is evaluated **once**, before the loop, not on every pass, and that `range` itself costs almost nothing to construct regardless of how large the span is: it holds three integers and computes each value on demand. Note that evaluating `len(items)` once has a consequence: if the loop body changes the length of `items`, the index range no longer matches the container and you can walk off the end or skip positions. Sizing the loop before the loop runs is a promise about the container that the body must keep. ### How to answer this in an interview Do not answer "never use range(len(...))" — that is a slogan, and reviewers who hold it reject correct code. Answer with the rule: **iterate elements by default; introduce an index only when you can name the thing the index is for.** Then give one concrete case where it is right — in-place assignment or a neighbour comparison — and one rewrite you would apply where it is not.
- What happens if you call range(len(source)) where source is a generator object?It raises `TypeError: object of type 'generator' has no len()`. `len()` needs `__len__`, and a generator object has no length because it has not produced its values yet. Wrapping it in `list()` to make the index loop work throws away the laziness that motivated the generator; if you need positions over a lazy source, use `enumerate`, which works on any iterable.
- Why is an index loop slower per pass than direct iteration?Direct iteration pulls one value from the iterator per pass. The index form does that for the integers *and* then looks up the sequence name and performs a bounds-checked subscript to fetch the element. It is a constant-factor difference, not an order-of-growth one, so it is a readability argument first and a performance argument a distant second.
- How do you replace an in-place transform loop without using indexes at all?Build the new values with a comprehension. If you can rebind the name, `items = [f(x) for x in items]` is clearest. If other references hold the same list object and must see the change, slice-assign into it: `items[:] = [f(x) for x in items]`, which mutates the existing list rather than creating a second one.
Reading a book by page number when you only want to read it front to back: the numbers are real and sometimes necessary, but announcing each one adds nothing unless you plan to go back to one.
saying these in an interview costs you the question
- Calls range(len(x)) the normal way to loop in Python
- Claims range(len(x)) copies or materialises the list
- Says an index is never needed in idiomatic Python
- Reaches for range(len(x)) on a generator object with no length
- Argues the only problem with range(len(x)) is speed
- Believes rebinding the loop variable updates the list