Why must a custom sequence's `__getitem__` handle negative indexes itself?
answer
- The interpreter does no arithmetic on it
- Built-in types do it for themselves
- Nothing consults __len__ on your behalf
- Add the length before bounds-checking
- slice.indices(len) clamps the rest
basics
~20 sPython passes the subscript straight through, so a custom class receives -1 unchanged. Only built-in types like list and str wrap negative values around internally. Your method must add the length itself, or the lookup fails.
solid answer
~40 sNothing normalizes the key for you. `obj[-1]` dispatches to `type(obj).__getitem__(obj, -1)` and the interpreter passes `-1` through untouched — it never consults `__len__` to rewrite it. Built-in types such as `list` and `str` support negative indexing because each implements the wrap-around inside its own subscription code, not because the protocol provides it. So a custom `__getitem__` that computes values, or guards with `0 <= index < len(self)`, rejects `-1` outright. The fix is three steps: coerce the key with `operator.index`, then `if index < 0: index += len(self)`, then bounds-check and raise `IndexError`. Slices are raw too — `obj[-3:]` delivers `slice(-3, None, None)` — so call `slice.indices(len(self))` to get a clamped `(start, stop, step)` triple. `__setitem__` and `__delitem__` need the same normalization.
code
python · 19 linesclass Squares:
def __init__(self, n):
self.n = n
def __len__(self):
return self.n
def __getitem__(self, index):
if not 0 <= index < self.n:
raise IndexError(index)
return index * index
s = Squares(5)
print(len(s), s[4])
try:
s[-1]
except IndexError as exc:
print("negative index rejected:", exc)go deeper
Recall that -1 means the last item on built-in lists and strings, and that len(obj) and obj[i] on your own class come from len and getitem. Be ready to name which method a subscript actually calls.
Explain that the key reaches getitem unchanged, so you add len(self) to a negative index yourself before bounds-checking and raise IndexError if it is still out of range. Know operator.index and slice.indices by name.
Show the failure mode in real code: a computed or remote-backed sequence whose guard reads 0 <= index < n rejects every negative index a caller writes, and the bug only appears once delegation to an internal list is dropped. Cover setitem and delitem too.
Own how sequence-like a type should pretend to be. Partial protocol support that quietly mishandles negative indexes and slices is worse than a narrow explicit interface, so decide where your types stop imitating built-in sequences and make that boundary visible to callers.
### Subscription does no arithmetic When CPython evaluates `obj[key]`, it dispatches to `type(obj).__getitem__(obj, key)` and hands `key` over as an ordinary object. There is no interpreter step that inspects `__len__`, notices a negative integer and rewrites it. A class that defines `__getitem__` therefore receives exactly what was written between the brackets: `obj[-1]` delivers the integer `-1`, and `obj[-3:]` delivers `slice(-3, None, None)` with its `start` still negative. ### Why the built-ins look like they get it for free `list`, `tuple`, `str`, `bytes` and `range` all support `seq[-1]`, and that is where the misconception starts. Each of those types implements the wrap-around inside its own subscription code — it is a property of the type, not of the protocol. Nothing in the language grants that behaviour to a class you write. Some C-level sequence entry points do normalize against the length, which is where the folklore comes from, but the ordinary subscription path that reaches your `__getitem__` does not go through them. ### Delegation hides the bug Most first drafts appear to work, because `__getitem__` simply forwards to a stored list: `return self._items[index]`. The list normalizes, so `-1` behaves. The defect surfaces the moment the class stops delegating — when it computes values from the index, wraps a mapping or a file offset, pages a remote store, or merely bounds-checks with `if not 0 <= index < len(self): raise IndexError(index)`. That last shape is the common one: a perfectly reasonable guard that rejects every negative index a caller writes. ### What a correct `__getitem__` does, in order 1. **Coerce the key** with `operator.index`. It accepts anything implementing `__index__` and raises `TypeError` for a `float` or a `str`, which is exactly what built-in sequences do. `int(key)` is the wrong tool — it would silently truncate `2.7` and happily parse `"2"`. 2. **Wrap**: `if index < 0: index += len(self)`. Do it once, before the bounds check, so `-1` lands on the last valid position and `-len(self)` on the first. 3. **Bounds-check and raise `IndexError`** — not `TypeError`, not `ValueError`, and never return `None`. `IndexError` is the agreed end-of-sequence signal: it is what stops code that walks a sequence by ascending index, and what `reversed()` and iterable unpacking rely on to recognise the end. ### Slices arrive raw too, and the stdlib normalizes them for you A slice object carries whatever the caller typed — negative bounds, `None` for omitted positions, a negative step. `slice.indices(length)` converts all of that into a concrete `(start, stop, step)` triple already clamped to the length: `slice(-3, None, None).indices(5)` returns `(2, 5, 1)`. Feed it to `range()` and you get precisely the positions the caller meant, including the awkward negative-step case. Hand-rolling that arithmetic is the single most common place custom sequences go wrong, because the clamping rules for a negative step are genuinely fiddly. ### The neighbouring dunders inherit the problem `__setitem__` and `__delitem__` take the same key and need the same normalization; a class that fixes only reads will still fail on `obj[-1] = x` or `del obj[-1]`. `__len__` has its own contract alongside: it must return a non-negative integer that fits the platform's index type, or `len()` raises `ValueError` with a message about returning `>= 0`. And because `__bool__` falls back to `__len__` when it is absent, a broken length quietly breaks truthiness as well. ### How to check it in ten seconds You never have to reason about this from memory. A throwaway class whose `__getitem__` simply returns its argument tells you exactly what the subscription machinery delivered: `Probe()[-1]` shows `-1`, and `Probe()[-3:]` shows `slice(-3, None, None)`. The same probe answers every neighbouring question — what an omitted bound becomes, what an `Ellipsis` key looks like, what the tuple subscript `obj[1, 2]` delivers — and building that habit is far more reliable than trusting a remembered rule about which conversions the interpreter supposedly applies on your behalf. ### Why interviewers ask it The question separates people who have *used* the protocols from people who have *implemented* them. The answer is not a fact to memorize but a stance: the data model hands your method the raw request, and every convenience built-in types appear to offer — negative indexing, slice normalization, well-behaved errors — is work those types do for themselves. If your class wants to look like a sequence, it does that work too. A candidate who reaches for `operator.index` and `slice.indices` instead of a hand-rolled `if` chain is signalling they have written this code before and been bitten by its edges. A candidate who insists the interpreter handles it has only ever been on the calling side.
- Why should an out-of-range index raise IndexError rather than TypeError?`IndexError` is the agreed end-of-sequence signal. It is what stops code that walks a sequence by ascending index, and what `reversed()` and iterable unpacking rely on to recognise the end. A `TypeError` or `ValueError` propagates to the caller instead of stopping the walk, so a loop that should have finished cleanly crashes. Raise `IndexError` even when the underlying failure was a bad offset in a remote store.
- How do you resolve a negative or open-ended slice inside __getitem__?Call the slice object's `indices()` method with your length: `slice(-3, None, None).indices(5)` returns `(2, 5, 1)`, already clamped with the `None` filled in. Pass that to `range()` and you get exactly the positions to fetch, including the fiddly negative-step case where start and stop invert. Hand-rolling the arithmetic is where most custom sequences get slicing wrong.
- Why prefer operator.index(key) over int(key) when coercing a subscript?`operator.index` accepts only objects implementing `__index__` — genuine integer types — and raises `TypeError` for a `float` or a `str`, which is exactly how built-in sequences behave. `int()` would truncate `2.7` to `2` and parse `"2"` into an integer, so a caller's mistake that should have been rejected loudly becomes a silently wrong lookup instead.
Subscription is a mail slot, not a receptionist: whatever you wrote on the envelope is what lands on your desk. If -1 is supposed to mean “the last one”, you are the one who has to look up which one that is.
saying these in an interview costs you the question
- Says Python rewrites obj[-1] into obj[len(obj)-1] automatically
- Thinks defining __len__ is what enables negative indexing
- Expects __getitem__ to receive a slice with concrete non-negative bounds
- Raises TypeError or ValueError instead of IndexError when out of range
- Uses int(key) rather than operator.index, silently accepting floats
- Fixes only __getitem__ and forgets __setitem__ and __delitem__