Why can a Python class with only `__getitem__` and no `__iter__` be looped over?
answer
- Two iteration protocols, not one
- The older one predates __iter__
- Indexing starts at zero and counts up
- One specific exception ends the loop
- The abc check still says False
basics
~20 sIt hits the legacy sequence-iteration fallback: with no iter, iter() builds an iterator that calls obj[0], obj[1], obj[2] and so on until IndexError is raised. The class works in a for loop yet isinstance against collections.abc.Iterable still reports False.
solid answer
~40 sBefore `__iter__` existed, Python iterated sequences by index, and CPython still keeps that path. When `iter(obj)` finds no `__iter__` but does find `__getitem__`, it returns a plain sequence iterator that calls `obj[0]`, `obj[1]`, ... and stops when `IndexError` is raised. The requirements are strict: the class must accept consecutive integer keys starting at 0, and it must raise `IndexError` -- not `KeyError`, not return `None` -- to terminate. A mapping-like class keyed by strings therefore looks iterable to `for` and then blows up with `KeyError: 0`, and a class that never raises `IndexError` loops forever. The fallback is also invisible to introspection: `isinstance(obj, collections.abc.Iterable)` is `False` because the ABC's `__subclasshook__` only checks for `__iter__`. In new code, define `__iter__`; the fallback is for reading old code, not writing it.
code
python · 13 linesimport collections.abc
class Alphabet:
def __getitem__(self, i):
if i >= 3:
raise IndexError(i)
return "abc"[i]
print(list(Alphabet()))
print(isinstance(Alphabet(), collections.abc.Iterable))
print(iter(Alphabet()))go deeper
Just remember the shape: with no __iter__, Python tries obj[0], obj[1], ... and stops on IndexError. In your own classes, write __iter__ rather than relying on this.
Explain the mechanics -- where the fallback lives (inside iter()), what terminates it, and why isinstance against collections.abc.Iterable still says False. Recognise KeyError: 0 from a mapping-style __getitem__.
Point at the operational consequences: an O(n) __getitem__ turns a loop into O(n squared), a missing IndexError becomes an unbounded loop, and duck-type guards silently treat the object as a scalar. Prefer inheriting collections.abc.Sequence.
Frame it as protocol debt. Decide whether the codebase standardises on the ABCs so that iterability is checkable and typeable, and where legacy __getitem__-only objects at library boundaries get adapted rather than tolerated.
Python has two iteration protocols, and the older one is still wired into `iter()`. ### What `iter()` actually does Given `iter(obj)`, CPython looks for `__iter__` on the *type*. If it finds it, it calls it and checks that the result is an iterator. If it does not, it looks for `__getitem__`. If that exists, it manufactures a **sequence iterator**: an internal object holding a counter starting at 0, whose `__next__` calls `obj[i]`, increments `i`, and converts an `IndexError` into `StopIteration`. Only if neither dunder is present does `iter()` raise `TypeError: 'X' object is not iterable`. That fallback is the pre-2.2 protocol, kept for compatibility, and it is still present in CPython 3.14. It is why an ancient class with nothing but `__getitem__` still works in a modern `for` loop, and it is why the class in the example below iterates without ever mentioning iteration. ### The contract the fallback imposes Three conditions must hold, and each is a real bug when it does not. **Keys are consecutive integers from 0.** The synthesised iterator counts 0, 1, 2, ... regardless of what your keys mean. A class indexed by strings, tuples or 1-based positions is walked with the wrong keys. **Termination is `IndexError` and only `IndexError`.** That is the single exception the sequence iterator translates into `StopIteration`. Raise `KeyError` -- exactly what a dict-backed `__getitem__` does when asked for `0` -- and it propagates straight out of the `for` statement, so the loop dies with `KeyError: 0` in code that never mentioned a key. Return a sentinel instead of raising, and the loop never ends: this is the classic accidental infinite loop, and if the body appends to a list it becomes unbounded memory growth rather than a hang. **Every access goes through `__getitem__`.** If indexing is O(n) -- a linked structure, a remote fetch -- iterating is O(n squared) or one request per element, with no warning anywhere in the loop. ### The introspection gap This is the part interviewers actually probe. `isinstance(obj, collections.abc.Iterable)` returns `False` for a `__getitem__`-only class, because `Iterable.__subclasshook__` checks for the presence of `__iter__` and nothing else. So you get an object that a `for` loop happily consumes but that every duck-type guard rejects. Library code doing `if isinstance(value, collections.abc.Iterable)` will take the "not iterable" branch and treat your sequence as a scalar. Static type checkers behave the same way: annotating such an object as `Iterable[str]` is rejected, because the structural protocol requires `__iter__`. The converse case is worth naming too. A class defining `__getitem__` for a mapping is now accidentally loopable as far as `iter()` is concerned -- the failure surfaces only when the loop runs and the first key lookup raises. Defining `__iter__` explicitly, even one that raises `TypeError`, closes that door. ### `reversed()` uses a parallel fallback `reversed(obj)` first looks for `__reversed__`. If it is absent, it falls back to the **sequence protocol** -- `__len__` plus `__getitem__` -- and walks indices from `len(obj) - 1` down to 0. So a class with both of those is reversible for free, while a class with only `__iter__` is not: `reversed()` on it raises `TypeError: argument to reversed() must be a sequence`. If your iterable can be walked backwards cheaply, define `__reversed__` and return a reversed iterator; if it cannot, leaving `reversed()` to fail loudly is better than materialising the whole thing behind the caller's back. ### What to do in code you own Define `__iter__`. It is one method, usually a generator, it is what every ABC and type checker looks for, and it decouples iteration order from indexing. Define `__getitem__` when indexing is genuinely part of the API, and if you want the full sequence behaviour, inherit from `collections.abc.Sequence`: supply `__len__` and `__getitem__`, and you are given `__iter__`, `__contains__`, `__reversed__`, `index` and `count`, all consistent with each other and all visible to `isinstance`. Know the fallback because you will meet it in old code, in thin wrappers over C extensions, and in the odd test double -- and because the `KeyError: 0` traceback is unintelligible until you have seen it once.
- What must `__getitem__` raise to end the fallback loop, and what happens if it raises something else?`IndexError`, which the synthesised sequence iterator converts into `StopIteration`. Any other exception propagates out of the `for` statement -- a dict-backed `__getitem__` raising `KeyError: 0` is the usual case, and the traceback names a key the code never mentioned. Returning a sentinel instead of raising is worse still: the loop never terminates.
- How does `reversed()` decide whether a custom class can be reversed?It calls `__reversed__` if the class defines one. Otherwise it falls back to the sequence protocol -- `__len__` plus `__getitem__` -- and walks indices down from `len(obj) - 1`. With neither available it raises `TypeError`, so a class defining only `__iter__` is not reversible. Define `__reversed__` when a backwards pass is cheap, and let it fail loudly when it is not.
- Why does `isinstance(obj, collections.abc.Iterable)` return `False` for such a class?`Iterable` implements `__subclasshook__`, which reports a structural match only when the type defines `__iter__`. The fallback lives inside `iter()`, not in the type's attributes, so there is nothing for the hook to see. The practical consequence is that guard code and static type checkers both classify the object as non-iterable even though `for` consumes it fine.
saying these in an interview costs you the question
- Thinks __getitem__ iteration starts at index 1
- Says KeyError also terminates the fallback loop
- Expects isinstance against Iterable to return True
- Believes the fallback was removed in Python 3
- Claims __iter__ is synthesised onto the class
- Assumes __iter__ alone makes an object reversible