Home › Python › IndexError: Fix List Index Out of Range in Python
Beginner 6 min · September 23, 2026

IndexError: Fix List Index Out of Range in Python

IndexError means your index points past the end of the list.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 12 min
  • ✓Basic Python lists and for loops
  • ✓Running scripts with python3 and reading tracebacks
  • ✓Familiarity with range() and len()
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • The fix: your index is past the end, so use items[len(items) - 1] for the last slot, loop with range(len(items)), and guard reads with if i < len(items).
  • Empty lists have no valid index, so rows[0] on [] always raises; use rows[0] if rows else None for data that can come back empty.
  • Negative indexes only span -len(items) to -1, so items[-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 print len() there to see the mismatch.
✦ Definition~90s read
What is Python IndexError List Index Fix?

IndexError is the exception Python raises when a sequence index doesn't map to a real slot. For a list of length n, CPython's list subscript path checks 0 <= index < n (after translating negatives by adding n) and raises IndexError: list index out of range when the check fails — before touching any element storage.

★
Picture a hotel hallway with 3 rooms numbered 0, 1, 2.

Tuples and strings behave the same way with their own messages (tuple index out of range, string index out of range), because all three are sequences with positional slots. What IndexError is NOT matters just as much. It is not a KeyError: dicts hash their keys and raise KeyError on a miss, a completely different lookup path.

It is not a TypeError: indexing with a string or None raises TypeError because the index type is wrong, not its value. It is not an AttributeError or NameError, which come from dot access and bare names. And crucially, it is never raised by slicing — slices clip to the list edges by design.

So when the traceback says IndexError, you know three facts instantly: the container is a sequence, the index type was fine, and exactly one position fell outside 0..n-1 (or -n..-1). That's a tighter starting point than most exceptions give you.

Plain-English First

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(len()) loops with a + 1 that overshoots, pagination code that assumes every page is full, pop() or remove() calls that shrink a list mid-loop, and straight-line reads like 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 len() 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 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.

indexerror_bounds.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
orders = ["o-101", "o-102", "o-103"]
print("length:", len(orders))
print("valid slots: 0 to", len(orders) - 1)
print("last:", orders[len(orders) - 1])

# Index equal to len() is one past the end
try:
    print(orders[len(orders)])
except IndexError as exc:
    print("orders[3] -> IndexError:", exc)

# Empty lists have no valid index at all
rows = []
print("empty guard:", rows[0] if rows else None)
📊 Production Insight
Overnight batch jobs crash on this more than any other shape. Daytime data is never empty, so rows[0] looks safe until the 2 AM heartbeat page arrives with zero records.
🎯 Key Takeaway
Valid slots run 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(len()) idiom exists to visit every valid position exactly once, because 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 range() (which excludes its stop) with the list (which they fear excludes its last item). 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 len() for the rare cases (sliding windows, pairwise compares) where you genuinely need it, and there write range(len(items) - 1) deliberately. Reviewers catch this class in seconds by scanning loop bounds for + 1 and - 1. Every adjustment to a range() stop deserves a boundary test: one run with a single-element list and one with an empty list. 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.

indexerror_range.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
items = ["red", "green", "blue"]

# Correct: range(len()) stops at len() - 1
for i in range(len(items)):
    print(i, items[i])

# The classic overshoot: + 1 walks one past the end
try:
    for i in range(len(items) + 1):
        print(items[i])
except IndexError as exc:
    print("overshoot -> IndexError:", exc)

# Cleaner: let enumerate() own the counter
for i, color in enumerate(items):
    print(i, color)
📊 Production Insight
In production this off-by-one hides behind retries. A wrapper that re-fetches on failure can mask the crash on full batches for months; then a short page exhausts the retry budget and the job dies at 2 AM.
🎯 Key Takeaway
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.

indexerror_negative.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
letters = ["x", "y", "z"]
print("last:", letters[-1])
print("first via negative:", letters[-len(letters)])

for bad in [-4, -99]:
    try:
        print(letters[bad])
    except IndexError as exc:
        print(bad, "-> IndexError:", exc)

# Normalize a wild offset so it always lands on a slot
wild = -7
print("normalized:", letters[wild % len(letters)])
🔥The Two Spans That Matter
If you remember one span, make it this: positives run 0 to len - 1, negatives run -len to -1. Anything outside either span raises.
📊 Production Insight
Sliding-window code over short histories triggers this constantly. A 30-day window against a 4-day-old account computes items[-30] on day one — validate the window against -len() first.
🎯 Key Takeaway
Negatives are only valid from -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.

indexerror_slicing.pyPYTHON
1
2
3
4
5
6
7
8
9
10
nums = [1, 2, 3]
print(nums[5:9])      # [] — start past the end
print(nums[10:])      # [] — entirely past the end
print(nums[:100])     # [1, 2, 3] — stop clipped to len
print(nums[-100:2])   # [1, 2] — start clipped to 0
print([][0:5])        # [] — slicing an empty list is fine

# Practical: safe fixed-size chunks, short last page included
page = nums[2:5]
print("page:", page if page else "no more rows")
📊 Production Insight
Paging code is where slicing pays for itself. One team replaced bracket reads in their batcher with batch[i:i + 500] and short final pages stopped paging anyone -- the loop processes a smaller last chunk and exits cleanly.
🎯 Key Takeaway
Slices clip bounds to the list edges, so speculative reads should slice while proven positions use brackets.

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 pop() or remove() inside shortens the list while 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 pop() 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 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.

indexerror_mutation.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
queue = [10, 20, 30, 40]
processed = []
while queue:
    processed.append(queue.pop(0))
print("drained:", processed)

# Two-pass filter: collect first, delete after
scores = [5, 8, 3, 9, 2]
bad = [s for s in scores if s < 6]
for s in bad:
    scores.remove(s)
print("kept:", scores)

# Or build the survivors directly
scores = [5, 8, 3, 9, 2]
print("filtered:", [s for s in scores if s >= 6])
📊 Production Insight
Queue-drain crashes love deploys. A consumer that pops inside an indexed loop works until traffic spikes shorten the queue mid-pass; the while items: pop() shape survives any depth.
🎯 Key Takeaway
Indexed loops snapshot the length up front, so popping inside skips items then overshoots — copy, drain, or filter instead.

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 len() check (look before you leap) fits indexes your own code computes: 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 len() feeding it, and the culprit confesses. One hard rule: catch 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 len() feeding it, and the culprit confesses within minutes -- no debugger required.

⚠ Narrow the try, Name the Except
Keep the try block to the single indexing line. The moment it spans setup code, you're catching bugs you never meant to forgive.
📊 Production Insight
On-call engineers settle 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.
🎯 Key Takeaway
Guard computed indexes with len(), catch speculative ones with a narrow try/except — the traceback always names the line.
● Production incidentPOST-MORTEMseverity: high

The Midnight Sync That Asked for Record 500 in a 500-Record Batch

Symptom
The 1:40 AM warehouse sync died with 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.
Assumption
The team assumed every batch from the warehouse API held exactly 500 records, because it always had during testing. The loop used 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.
Root cause
The sync loop ran 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.
Fix
Three changes shipped together. First, the loop bound became 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.
Key lesson
  • 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 len() 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.
Production debug guideFive crash shapes that cover nearly every IndexError page — each with the one command that names the bad index.5 entries
Symptom · 01
Traceback ends with IndexError: list index out of range but you don't know which access failed
→
Fix
Rerun the failing script so the full traceback prints, then read the second-to-last frame: python 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.
Symptom · 02
A range(len(...)) loop crashes on its last pass
→
Fix
Add one probe line above the crash: python -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.
Symptom · 03
rows[0] crashes only sometimes, usually at night or on small batches
→
Fix
Log the payload size at the read site: print('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.
Symptom · 04
A negative index like items[-4] raises even though "negatives are safe"
→
Fix
Test the suspect negative index in isolation: 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.
Symptom · 05
Pagination or windowing code dies on the last, short page
→
Fix
Replace the speculative bracket read with a slice and rerun: 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.
IndexError Root Causes at a Glance
Root CauseHow to ConfirmFixPrevention
Index is equal to or larger than len()Print len(items) next to the index at the crash line; the index reads len or higherUse items[len(items) - 1] for the last slot, or guard with if i < len(items)Loop with range(len(items)) or enumerate() so the bound can't drift
Empty-list access with [0] or [-1]print(len(rows)) shows 0 at the failing line; the data source returned zero recordsRead defensively: rows[0] if rows else defaultTreat empty results as a normal branch in every query handler
Negative index past -len(items)-len(items) math shows the index is smaller, e.g. -4 against a 3-item listClamp with max(idx, -len(items)) or normalize with moduloValidate −len <= i < len in helpers that accept user-supplied offsets
Off-by-one in hand-rolled range() boundsTraceback shows i == len(items) on the last pass; range(len(items) + 1) in sourceDrop the + 1, or switch to enumerate()Ban len(...) + 1 in loop bounds during review; prefer direct iteration
List mutated (pop/remove) during indexed loopLogging len(items) each pass shows it shrinking while i keeps risingIterate over list(items) copy, or drain with while items: pop()Never mutate the list you're indexing; split filter-then-process into two passes
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
indexerror_bounds.pyorders = ["o-101", "o-102", "o-103"]Your Index Hit len()
indexerror_range.pyitems = ["red", "green", "blue"]range(len()) Off-by-One
indexerror_negative.pyletters = ["x", "y", "z"]Negative Indexes Stop at -len
indexerror_slicing.pynums = [1, 2, 3]Slicing Never Raises
indexerror_mutation.pyqueue = [10, 20, 30, 40]Pop Inside the Loop

Key takeaways

1
Valid positions are 0 to len() - 1; index len() itself is always one past the end and always raises.
2
Empty lists have no valid index, so guard rows[0] with if rows or a default before you read.
3
Negative indexes only span -len() to -1; anything smaller raises just like an oversized positive index.
4
Slicing never raises
it clips to the edges — so use slices for speculative reads and brackets for proven positions.
5
Never pop() or remove() inside an indexed loop; iterate over a copy or drain with while.
6
The traceback names the exact indexing expression, so print len() at that line and the mismatch stares back at you.

Common mistakes to avoid

5 patterns
×

Looping with `range(len(items) + 1)` to "include the last item"

Symptom
The loop prints every element correctly, then crashes with IndexError on the final pass when i equals len(items).
Fix
Use 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

Symptom
IndexError fires at 2 AM when the upstream API returns an empty payload, even though the code worked all day with non-empty data.
Fix
Guard the read: 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"

Symptom
items[-4] on a 3-item list raises IndexError, surprising anyone who believed negative indexes can't go out of range.
Fix
Normalize first: 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

Symptom
The loop skips elements, then crashes near the end because the list shrank while the index kept climbing toward the original length.
Fix
Loop over a copy (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

Symptom
Pagination or windowing code crashes on the last page instead of returning a short final chunk like the developer expected.
Fix
Use slices for "maybe there" reads: chunk = items[i:i+3] returns [] when past the end. Keep bracket indexing for positions you've proven valid.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why does `items[len(items)]` always raise IndexError?
Q02JUNIOR
What are the valid negative indexes for a list, and what happens past th...
Q03SENIOR
Why does single-position indexing raise but slicing with the same number...
Q04SENIOR
What goes wrong when you pop() inside an indexed loop? Give two safe pat...
Q05SENIOR
When is a len() guard better than try/except IndexError, and when is it ...
Q01 of 05JUNIOR

Why does `items[len(items)]` always raise IndexError?

ANSWER
Valid positions for a list of length n are 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].
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can an index be "too big" but still work?
02
Why does `items[5:9]` return `[]` instead of raising?
03
Is `items[len(items)]` ever valid?
04
Does this error happen with dicts too?
05
Should I wrap the whole function in try/except IndexError?
06
Is try/except faster than checking len() first?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
🔥

That's Errors. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
Python KeyError Dict Lookup Fix
2 / 18 · Errors
Next
Python ModuleNotFoundError Fix
→