IndexError: Fix List Index Out of Range in Python
IndexError means your index points past the end of the list.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Basic Python lists and for loops
- ✓Running scripts with python3 and reading tracebacks
- ✓Familiarity with
range()andlen()
- The fix: your index is past the end, so use
items[len(items) - 1]for the last slot, loop withrange(len(items)), and guard reads withif i < len(items). - Empty lists have no valid index, so
rows[0]on[]always raises; userows[0] if rows else Nonefor data that can come back empty. - Negative indexes only span
-len(items)to-1, soitems[-4]on 3 items still raises; normalize with modulo or clamp first. - Read the traceback frame above
IndexError: it names the file, line, and expression, so printlen()there to see the mismatch.
Picture a hotel hallway with 3 rooms numbered 0, 1, 2. You ask the clerk for room 3, but there is no room 3 — the hallway simply ends. That's IndexError: your number points past the last real door. An empty list is a hallway with no rooms at all, so even room 0 doesn't exist. Negative numbers count back from the end (room -1 is the last door), but ask for room -4 in a 3-room hallway and you're out in the parking lot — same error. Slicing just shrugs and hands you an empty list.
You've written a loop that works on every test case, deployed it with confidence, and at 2 AM your phone buzzes: IndexError: list index out of range. Nothing about the code changed. What changed was the data — an empty API response, a short batch on the last page, one record fewer than yesterday. This error fires when your code asks for a list position that doesn't exist: index 3 on a 3-item list (valid slots are 0–2), [0] on an empty list, or -4 on a 3-item list. It shows up most often in four places: hand-rolled range( loops with a len())+ 1 that overshoots, pagination code that assumes every page is full, or pop() calls that shrink a list mid-loop, and straight-line reads like remove()rows[0] after a query that sometimes returns nothing. The fix is usually a one-line guard or a corrected bound, but finding which line matters — the traceback points at the exact indexing expression, and at that spot tells the whole story. This article walks through reading that traceback, the negative-indexing rules that surprise people, why slicing never raises, and when a len() check beats len()try/except (and vice versa).
Your Index Hit len(): Why Valid Positions Stop One Short
A list of length n owns exactly n slots, numbered 0 through n - 1. That's the entire rule, and every IndexError is this rule being broken: the code asked for a slot outside that span. The most common violation is indexing with len(items) itself — on a 3-item list that's items[3], one past the last real slot 2. It reads naturally ("the item at the length") but it's always wrong, and Python raises instead of returning None or wrapping around. The second classic is the empty list. [] has length 0, which means it has no valid indexes whatsoever — not even [0]. Code like rows[0] after a database query or API call works for months, then crashes the first night the upstream returns zero records. Both cases share one diagnostic: print len() beside the index at the crash line and the mismatch is obvious. The fix is mechanical. For the last element write items[len(items) - 1] (or items[-1]), and for possibly-empty data read defensively with rows[0] if rows else default. These aren't style preferences; they're the boundary check the list itself enforces. The traceback helps more than most beginners expect. Its final line names the exception, and the frame just above it prints the exact source text of the failing read plus the file and line number. When several bracket reads share one line, split them across lines and rerun -- the guilty expression identifies itself. Tuples and strings enforce the same span with their own messages (tuple index out of range, string index out of range), so the same counting rule transfers directly.
rows[0] looks safe until the 2 AM heartbeat page arrives with zero records.0 to len() - 1, so items[len(items)] always raises; guard possibly-empty reads with if rows.range(len()) Off-by-One: The Extra +1 That Crashes the Last Pass
The range( idiom exists to visit every valid position exactly once, because len())range(n) yields 0 through n - 1 — the same span as the list's slots. The crash creeps in when someone "fixes" the loop by adding + 1, usually after confusing (which excludes its stop) with the list (which they fear excludes its last item). range()range(len(items) + 1) yields one extra value, len(items) itself, and the body reads one past the end on its final pass. What makes this nasty is timing: the loop prints every correct element first, so logs look healthy right up to the traceback. It also hides in refactors — a loop that once iterated range(count) where count was already len - 1 gets "simplified" into the overshoot. The repair is to delete the + 1, but the durable fix is to stop hand-rolling counters. for item in items needs no bound at all, and for i, item in enumerate(items) hands you correct indexes that can't drift. Reserve arithmetic on for the rare cases (sliding windows, pairwise compares) where you genuinely need it, and there write len()range(len(items) - 1) deliberately. Reviewers catch this class in seconds by scanning loop bounds for + 1 and - 1. Every adjustment to a stop deserves a boundary test: one run with a single-element list and one with an empty list. range()enumerate(items, start=1) covers display numbering without touching the index math, and reversed(range(len(items))) covers backward walks. When the bound is right, the loop has no way to overshoot.
range(len()) already covers every slot; the + 1 adds an index one past the end — prefer enumerate().Negative Indexes Stop at -len: When -4 on 3 Items Raises
Negative indexes count back from the end: -1 is the last element, -2 the one before, down to -len(items) which is the first. Newcomers often conclude negatives are inherently safe — they can't overshoot, right? They can. Anything smaller than -len(items) points before the start of the list, and Python raises IndexError exactly as it does for oversized positives. items[-4] on a 3-item list fails because the valid negative span for length 3 is -3 to -1. This bites in offset arithmetic: items[-window] where window grows past the list length, or items[idx - k] where a subtraction drags a small index below -len. The diagnostic is the same print-both-sides trick: show the index and -len(items) together. Fixes depend on intent. When wraparound is genuinely wanted, normalize with idx % len(items) so any integer lands on a real slot. When the offset is user input or a computed window, validate -len(items) <= idx before indexing and handle the short-data branch explicitly. And note the asymmetry that confuses people: -0 is just 0, so there's no negative zero slot — another reason to normalize rather than reason it out by hand. When in doubt, print both sides of the comparison: the index and -len(items) on one log line settle the question instantly, even at 2 AM.
0 to len - 1, negatives run -len to -1. Anything outside either span raises.items[-30] on day one — validate the window against -len() first.-len() to -1; normalize wild offsets with modulo or validate before indexing.Slicing Never Raises: Why [5:9] Returns [] Instead
Here's the asymmetry every Python programmer must internalize: items[9] on a 3-item list raises, but items[9:12] calmly returns []. Slicing never raises IndexError — not for wild starts, wild stops, empty lists, or reversed bounds (those return [] too). Under the hood, slice bounds get clipped to the [0, len] span before anything is fetched, so whatever fits is returned and nothing is an error. This isn't a quirk; it's the feature that makes pagination, chunking, and windowing code clean. batch[i:i + 500] on the final short page returns the remaining rows instead of crashing, and history[-30:] on a 4-item list returns all 4. The practical rule: use slices for speculative reads (positions that might not exist) and brackets for proven positions. When a crash report shows bracket indexing on a computed offset in paging code, the one-line fix is usually converting batch[i + k] into batch[i:i + k + 1] handling. One caution: clipping cuts both ways — a slice silently returning [] can mask a logic bug upstream, so check if chunk: when an empty result should be suspicious rather than normal. One caution: clipping cuts both ways -- a slice that quietly returns [] can mask a logic bug upstream, so check if chunk: when an empty result should look suspicious rather than normal. Pair slices with an explicit empty-branch and you get crash-proof reads without losing visibility into bad data.
batch[i:i + 500] and short final pages stopped paging anyone -- the loop processes a smaller last chunk and exits cleanly.Pop Inside the Loop: The Mutation That Skips and Crashes
The ugliest IndexError shape is the self-inflicted one: the loop's bound was valid when computed, then the body shrank the list out from under it. for i in range(len(items)) snapshots the original length up front; every or pop() inside shortens the list while remove()i keeps climbing toward the stale length. Two failures follow. Late passes read past the new end and raise, and earlier passes silently skip elements because everything after a removal shifts one slot left while i advances one right. Logs make this miserable to diagnose: half the items process fine, one vanishes without a trace, then the traceback fires pages away from the real cause. The fix is structural, not a bigger guard — stop mutating the list you're indexing. Draining loops should be while items: items.pop(0) (or from the end), which re-checks emptiness every pass. Filtering should run in two passes: collect targets with a comprehension, then delete, or better, build the survivor list directly. If you must index and delete in one pass, walk backward with pop()range(len(items) - 1, -1, -1) so removals only disturb slots you've already visited. The backward-walk exception proves the rule: range(len(items) - 1, -1, -1) stays valid because deletions only disturb slots you have already visited, never the ones ahead of the cursor.
while items: pop() shape survives any depth.len() Check vs try/except: Reading the Traceback to the Guilty Line
Two tools guard an indexing read, and picking the right one is a judgment call, not a habit. The check (look before you leap) fits indexes your own code computes: len()if i < len(items): use(items[i]). It's explicit, costs nothing, and documents the invariant for the next reader. The try/except IndexError (easier to ask forgiveness) fits speculative reads — user-supplied positions, plugin data, or lists another thread mutates between your check and your read. Its great virtue is atomicity: no gap between validation and access. Whichever you choose, the traceback is your compass. Python prints the exact file, line number, and source text of the failing expression in the frame above IndexError, so you never have to guess which of five bracket reads fired. Copy that expression, print each index and each feeding it, and the culprit confesses. One hard rule: catch len()IndexError and nothing broader. except Exception around indexing swallows TypeError from a None list and NameError from a typo'd variable, converting loud, fixable bugs into silent wrong behavior that pages you months later. Copy the failing expression from the traceback frame, print each index and each feeding it, and the culprit confesses within minutes -- no debugger required.len()
len()-vs-try debates with one rule: if your code computed the index, guard it; if the outside world supplied it, catch it. Mixed ownership gets both -- a guard for the expected empty case and a narrow try for the race.len(), catch speculative ones with a narrow try/except — the traceback always names the line.The Midnight Sync That Asked for Record 500 in a 500-Record Batch
IndexError: list index out of range three nights in a row. Each run processed its full pages, then crashed on the short final page. By morning 2,400 orders sat unshipped in the queue, the dashboard showed the job red for 6 hours, and customer support had 40 tickets about delayed shipments.range(len(batch) + 1) after a well-meaning "include the last item" edit, and nobody noticed it only survived because full batches masked the overshoot — until a short final page exposed it.for i in range(len(batch) + 1), so on a full 500-record batch the final pass read batch[500] — one past valid slots 0–499 — and raised IndexError. Full batches had always crashed at that line too, but a broad retry wrapper re-fetched and accidentally skipped the fatal line, hiding the bug. On the third night the warehouse returned a short 212-record final page plus an empty 0-record heartbeat page; the heartbeat hit rows[0] on [] and the retry budget ran out, halting the job with 2,400 orders unprocessed.range(len(batch)), cutting the max index from 500 to 499 on full 500-record batches. Second, the header read became first = rows[0] if rows else None, so the 0-record 2 AM responses take a clean retry path instead of crashing. Third, a size probe logs len(batch) per page, and the pager now fires only after 3 consecutive empty pulls within 15 minutes. The 2,400 stuck orders flushed through in 11 minutes on the rerun, and no batch has crashed in the 6 weeks since.- Never trust the shape of one page of data. Batches shrink, APIs return zero records at night, and any index tuned to a full page will overshoot a short one.
- Off-by-one edits need a boundary test, not just a happy-path run. A single test with a 1-record batch would have caught
range(len + 1)before it reached production. - Log
at every indexing site you own. When the page arrives with the index value beside it, the next IndexError takes minutes to diagnose instead of an hour.len()
IndexError: list index out of range but you don't know which access failedpython failing_job.py 2>&1 | tail -8. The frame above IndexError names the exact file, line number, and indexing expression. Open that line and print the index value and len() of the list right before it.range(len(...)) loop crashes on its last passpython -c "items = [1,2,3]; i = 3; print('len:', len(items), 'index:', i)" adapted to your values. If the index equals or exceeds len, you've found an off-by-one. Fix the bound with range(len(items)) or index len(items) - 1 for the last slot.rows[0] crashes only sometimes, usually at night or on small batchesprint('rows:', len(rows)) before rows[0]. When it prints 0, the upstream returned nothing. Fix with first = rows[0] if rows else None and handle the empty branch explicitly.items[-4] raises even though "negatives are safe"python -c "items=['a','b','c']; print(items[-4])" reproduces IndexError because valid negatives for 3 items span -3 to -1. Normalize with idx % len(items) or reject out-of-range offsets before indexing.python -c "items=[1,2,3]; print(items[5:8])" prints [] instead of raising. Use chunk = items[i:i+3] in pagination and windowing code so short final pages return short lists.| File | Command / Code | Purpose |
|---|---|---|
| indexerror_bounds.py | orders = ["o-101", "o-102", "o-103"] | Your Index Hit len() |
| indexerror_range.py | items = ["red", "green", "blue"] | range(len()) Off-by-One |
| indexerror_negative.py | letters = ["x", "y", "z"] | Negative Indexes Stop at -len |
| indexerror_slicing.py | nums = [1, 2, 3] | Slicing Never Raises |
| indexerror_mutation.py | queue = [10, 20, 30, 40] | Pop Inside the Loop |
Key takeaways
0 to len() - 1; index len() itself is always one past the end and always raises.rows[0] with if rows or a default before you read.-len() to -1; anything smaller raises just like an oversized positive index.pop() or remove() inside an indexed loop; iterate over a copy or drain with while.len() at that line and the mismatch stares back at you.Common mistakes to avoid
5 patternsLooping with `range(len(items) + 1)` to "include the last item"
IndexError on the final pass when i equals len(items).range(len(items)) or iterate directly with for item in items. When you need positions too, use for i, item in enumerate(items). Reserve + 1 for display numbers only, never for indexing.Indexing `rows[0]` straight after a query without checking for zero results
IndexError fires at 2 AM when the upstream API returns an empty payload, even though the code worked all day with non-empty data.first = rows[0] if rows else None. For APIs that can return zero records, treat the empty case as a normal branch, not an exception.Assuming any negative index is safe because "negatives count from the end"
items[-4] on a 3-item list raises IndexError, surprising anyone who believed negative indexes can't go out of range.idx = idx % len(items) when wraparound is intended, or reject with if -len(items) <= idx < len(items) before indexing.Removing elements with `pop()` inside a `for i in range(len(items))` loop
for x in list(items)) when removing, or drain with while items: items.pop(0). Collect-then-delete in two passes is the safest shape.Using bracket indexing for speculative reads that might be past the end
chunk = items[i:i+3] returns [] when past the end. Keep bracket indexing for positions you've proven valid.Interview Questions on This Topic
Why does `items[len(items)]` always raise IndexError?
0 through n - 1, so index n is one past the end. Python checks the index against the length before fetching and raises IndexError instead of returning garbage or None. The last element is always items[n - 1] or items[-1].Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Errors. Mark it forged?
6 min read · try the examples if you haven't