Skip to content

Fix: Python IndexError: list index out of range

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Python IndexError list index out of range caused by empty lists, off-by-one errors, wrong loop bounds, deleted elements, and negative indexing mistakes.

The Off-By-One That Bit You

I have written more off-by-one errors than any other class of bug in my career, and IndexError: list index out of range is what Python says when one of them surfaces at runtime. The fix is usually trivial; the embarrassment of having shipped it is not. You run a Python script and get:

Traceback (most recent call last):
  File "app.py", line 4, in <module>
    print(items[5])
IndexError: list index out of range

Or variations:

IndexError: string index out of range
IndexError: tuple index out of range

You tried to access an element at an index that does not exist. The list (or string, or tuple) does not have that many elements.

Quick Reference Before You Dive In

If you arrived here from Google with a fresh traceback, the five facts that resolve roughly 90 percent of cases:

  1. Python lists are zero-indexed; the last valid index is len(lst) - 1. A list with 5 elements has valid indices 0 through 4. Index 5 is out of range. The Python sequence types documentation is the canonical reference; the data model getitem docs explain how the error is raised.
  2. An empty list raises IndexError for ANY index, including lst[0] and lst[-1]. Guard with if lst: before subscripting. The if lst: pattern is more idiomatic than if len(lst) > 0:.
  3. The most common cause is len(lst) used as an index by mistake. for i in range(len(items) + 1) walks one past the end. Prefer iterating directly: for item in items:.
  4. Mutating a list while iterating shifts indices and produces IndexError (or silently skips elements). Use a list comprehension items = [x for x in items if keep(x)] or iterate in reverse.
  5. Slicing past the end is SAFE; indexing past the end is NOT. items[100:200] on a 10-element list returns [] without error. items[100] raises. If you can switch from indexing to slicing, you eliminate the error class.

The rest of this article walks through each cause in detail, plus the failure modes most other guides skip.

Why Zero-Indexing Matters Here

Python sequences are zero-indexed. A list with 5 elements has valid indices 0 through 4. Accessing index 5 is out of range:

items = ["a", "b", "c", "d", "e"]  # len = 5
items[0]   # "a" (first element)
items[4]   # "e" (last element)
items[5]   # IndexError, no 6th element

Common causes:

  • Empty list. The list has no elements, so any index fails.
  • Off-by-one error. Using len(items) as an index instead of len(items) - 1.
  • Loop modifying the list. Removing elements while iterating changes the list length.
  • Hardcoded index. Assuming data always has a certain number of elements.
  • API response with fewer items than expected. The response returned 2 items but you access response[3].

Most of these come down to one underlying mistake: trusting the shape of upstream data without checking it. A list returned from a database query, an HTTP response, a CSV parse, or a str.split() can all be empty or shorter than expected. Code written for the happy path assumes the data is always there. The day the upstream system has a hiccup (an empty result set, a malformed row, an API change) is the day IndexError shows up.

The error is also one of Python’s more honest failure modes. Unlike languages where reading past the end of an array returns garbage memory, Python raises immediately. That sounds good (you find the bug fast) but it also means the bug surfaces in places far from the real cause. A scheduled batch job that crashes at 3am because yesterday’s data feed dropped a column is not a Python problem; it is a contract problem between two systems. The fix usually lives at the boundary, not at the line that raised.

In Production: Incident Lens

IndexError in production almost always signals an upstream data contract change, not a coding bug. The code worked yesterday. The data shape today is different. Reading the error this way reframes the entire incident.

  • How it surfaces: In synchronous request handlers, it surfaces as a 500 error on a single endpoint, often only for some users (the ones whose data hit the bad shape). In batch jobs, ETL pipelines, or Celery/RQ workers, it surfaces as a hard crash that may halt the entire job, leaving partial state. Sentry/Rollbar usually catches it within seconds of the first occurrence with a clean stack trace pointing at the indexing line.
  • Blast radius: Per-request for web handlers, only requests that touch the affected code path fail. For batch jobs, blast radius is much wider: a single bad row can poison the entire run if the job is not idempotent or has no per-record error handling. The worst case is silent data corruption upstream: code that should have raised IndexError instead read a wrong value because someone “fixed” it with [0] if x else None without validating that the element at index 0 actually meant what they thought.
  • What catches it: Error tracking (Sentry, Rollbar, Honeybadger) is the fastest signal. SLO error-budget burn alerts fire if the affected endpoint is high-traffic. For batch jobs, watch for job-duration anomalies and partial-output alerts: a job that finished in 30 seconds instead of 30 minutes either succeeded brilliantly or died early.
  • Recovery sequence: For web handlers, rollback only if the new code introduced a stricter assumption. If the data shape changed independently of the deploy, rolling back will not help; the same IndexError will happen on the old code too. Forward-fix with a defensive guard, redeploy, then trace back to fix the upstream contract. For batch jobs, requeue failed records once a fix is shipped.
  • Postmortem preventive: The durable control is schema validation at the boundary, not bounds checks scattered through business logic. Use Pydantic, dataclasses with __post_init__, or jsonschema to validate inputs at the system edge. If the shape is wrong, fail loudly there with a clear message instead of silently propagating a malformed list into code that crashes ten layers deeper. Add contract tests against the upstream API so a breaking change shows up in CI, not production.

When to Use Which Fix

The next eight sections cover the fixes in detail. The table below maps your situation to the recommended fix.

Your situationRecommended fixWhy
Need a specific index that might not existFix 1: if len(items) > N or safe_get helperExplicit bounds check
for i in range(len(items) + 1) walks off the endFix 2: drop the + 1, or iterate directlyLast valid index is len - 1
List might be emptyFix 3: if items: guardEmpty list raises on any index
Loop modifies the list it iteratesFix 4: list comprehension, or iterate in reverseMutation shifts indices
parts = line.split(",") returned fewer parts than expectedFix 5: per-part bounds check, or unpack with *restUpstream data was smaller than assumed
Pandas df.iloc[N] or NumPy arr[N] out of boundsFix 6: check len(df) / arr.shape[0] firstSame rule, different sequence type
Multiple threads mutating a shared listFix 7: switch to queue.Queue or threading.LockRace condition between check and pop
Cannot tell where the bad index came fromFix 8: print length + index + contents, use enumerateBisect the indexing logic

If multiple rows apply, pick the topmost one.

Fix 1: Check the List Length First

Before accessing an index, verify the list has enough elements:

items = get_results()

if len(items) > 0:
    first = items[0]
else:
    first = None

For a specific index:

if len(items) > 3:
    fourth = items[3]

Using a helper function:

def safe_get(lst, index, default=None):
    return lst[index] if -len(lst) <= index < len(lst) else default

items = [10, 20, 30]
safe_get(items, 5)        # None
safe_get(items, 5, 0)     # 0
safe_get(items, 1)        # 20

In my own utility belt I have a tiny nth(items, n, default=None) helper because Python lists do not have a .get() like dicts do. If your codebase frequently reaches for “the first element if it exists,” it is worth adding a one-liner like next(iter(items), None) or a small helper to avoid the bare try/except IndexError style.

Fix 2: Fix Off-By-One Errors

The most common logic error. Lists are zero-indexed, so the last valid index is len(items) - 1:

Broken:

items = [10, 20, 30]

# Wrong: index 3 does not exist
for i in range(len(items) + 1):
    print(items[i])  # IndexError when i = 3

Fixed:

for i in range(len(items)):
    print(items[i])

Better: iterate directly:

for item in items:
    print(item)

Access the last element safely:

items = [10, 20, 30]
last = items[-1]         # 30 (negative indexing)
last = items[len(items) - 1]  # 30 (manual, avoid this)

Negative indices count from the end: -1 is the last element, -2 is second to last. But items[-1] still raises IndexError on an empty list.

Fix 3: Handle Empty Lists

Empty lists cause IndexError on any index access:

results = []
first = results[0]  # IndexError: list index out of range

Fix with a guard:

results = get_search_results(query)

if results:
    first = results[0]
    print(f"Top result: {first}")
else:
    print("No results found")

Fix with a default value:

first = results[0] if results else "No results"

Fix with try/except:

try:
    first = results[0]
except IndexError:
    first = None

The if results: check is cleanest for most cases. Use try/except when the empty case is genuinely exceptional.

Fix 4: Fix Loops That Modify Lists

Removing elements while iterating over a list changes the indices:

Broken:

items = [1, 2, 3, 4, 5]

for i in range(len(items)):
    if items[i] % 2 == 0:
        items.pop(i)  # List shrinks, but range doesn't
# IndexError: list index out of range

When you pop index 1 (value 2), the list becomes [1, 3, 4, 5]. Now index 3 is 5, and index 4 does not exist.

Fixed: iterate over a copy:

items = [1, 2, 3, 4, 5]
items = [x for x in items if x % 2 != 0]
# [1, 3, 5]

Fixed: iterate in reverse:

items = [1, 2, 3, 4, 5]
for i in range(len(items) - 1, -1, -1):
    if items[i] % 2 == 0:
        items.pop(i)

Iterating in reverse is safe because popping later indices does not affect earlier ones.

Fixed: filter:

items = list(filter(lambda x: x % 2 != 0, items))

A bug I have shipped twice: mutating a list while iterating over it. for item in items: items.remove(item) looks innocent but skips elements because remove() shifts the remaining elements and the loop’s internal pointer advances past the next item. The second time I hit it I added a lint rule. Use a list comprehension (items = [i for i in items if keep(i)]) or iterate in reverse instead.

Fix 5: Fix String Indexing

Strings are sequences too, and the same rules apply:

name = "Alice"
name[0]   # "A"
name[4]   # "e"
name[5]   # IndexError: string index out of range

Common case: split with fewer parts than expected:

line = "Alice"
parts = line.split(",")  # ["Alice"], only 1 part
name = parts[0]           # "Alice"
email = parts[1]          # IndexError!

Fixed:

parts = line.split(",")
name = parts[0] if len(parts) > 0 else ""
email = parts[1] if len(parts) > 1 else ""

Or with unpacking and defaults:

parts = line.split(",")
name, *rest = parts
email = rest[0] if rest else ""

If your string parsing involves JSON, see Fix: JSON parse unexpected token for format issues.

Fix 6: Fix Pandas and NumPy Indexing

Pandas DataFrames and NumPy arrays can also raise IndexError:

Pandas: iloc out of range:

import pandas as pd

df = pd.DataFrame({"name": ["Alice", "Bob"]})
df.iloc[5]  # IndexError: single positional indexer is out-of-bounds

Fixed:

if len(df) > 5:
    row = df.iloc[5]

NumPy:

import numpy as np

arr = np.array([1, 2, 3])
arr[5]  # IndexError: index 5 is out of bounds for axis 0 with size 3

Fix: Check shape before accessing:

if arr.shape[0] > 5:
    value = arr[5]

Fix 7: Fix Multithreaded List Access

Multiple threads accessing the same list can cause IndexError if one thread removes elements while another accesses them:

import threading

shared_list = [1, 2, 3, 4, 5]

def worker():
    while shared_list:
        item = shared_list.pop(0)  # Race condition!
        process(item)

Fixed: use a thread-safe queue:

from queue import Queue

q = Queue()
for item in [1, 2, 3, 4, 5]:
    q.put(item)

def worker():
    while not q.empty():
        item = q.get()
        process(item)
        q.task_done()

Or use a lock:

lock = threading.Lock()

def worker():
    with lock:
        if shared_list:
            item = shared_list.pop(0)
            process(item)

Fix 8: Debug the Index Error

When the error is not obvious, add debugging:

items = get_data()
index = calculate_index()

print(f"List length: {len(items)}")
print(f"Attempting index: {index}")
print(f"List contents: {items}")

value = items[index]

Use enumerate for safe indexed access:

for i, item in enumerate(items):
    print(f"Index {i}: {item}")

This never raises IndexError because enumerate only yields valid indices.

Sneaky Causes I Have Hit

If you have checked all the fixes above and the error keeps firing, these are the less obvious ones I have personally hunted down:

Check for nested lists. items[0][1] fails if items[0] has fewer than 2 elements, even if items has many elements.

Check for generators vs lists. Generators cannot be indexed:

gen = (x for x in range(10))
gen[0]  # TypeError: 'generator' object is not subscriptable

Convert to list first: list(gen)[0].

Check for deque maxlen. A collections.deque with maxlen automatically removes old elements, which might make expected indices invalid.

Check for custom __getitem__. If the object is a custom class, its __getitem__ method might raise IndexError for unexpected reasons.

Check for SQLAlchemy result rows. A query that returns zero rows still gives you a Result object. Calling .one() raises NoResultFound, but .first() returns None, and indexing into the result list before checking length raises IndexError. Always check if rows: before rows[0].

Check pagination cursors. If you paginate by index (results[page * size : (page + 1) * size]), slicing past the end is safe (Python returns an empty list). But if you then index into that empty slice (page_results[0]), you crash. Slicing is the right tool; indexing without a length check is not.

What Other Tutorials Get Wrong About IndexError

Most Python tutorials list the same fixes but frame them in ways that produce subtle bugs.

They recommend try / except IndexError: pass. Silently swallowing the exception loses the bug. The catch should compute a meaningful default or re-raise; bare pass hides the upstream data shape change that caused the error, letting it cascade silently into wrong results downstream.

They show len(items) > 0 instead of if items:. Both work. if items: is the idiomatic form and is also true for non-empty strings, dicts, sets, etc. The explicit len(items) > 0 is verbose for the same check. Articles that recommend the explicit form are translating Python from another language.

They miss the slice-vs-index safety asymmetry. lst[100:200] on a 10-element list returns [] without error. lst[100] raises. When you can use slicing (even slicing a single element with lst[N:N+1]), the entire error class disappears. Tutorials that focus only on index bounds miss this leverage.

They confuse mutating-during-iteration with indexing. Code like for item in items: items.remove(item) does not always raise IndexError; it silently skips elements because remove() shifts the remaining items and the iterator advances past the next one. Tutorials that group both bugs under “IndexError” miss that the no-error variant is actually worse: data loss instead of a crash.

They omit the upstream-data-contract framing. IndexError in production code is almost always a signal that the upstream data shape changed, not a coding bug. The durable fix is schema validation at the boundary (Pydantic, jsonschema), not bounds checks scattered through business logic. Tutorials that focus on per-call defensive code miss the architectural answer.

They miss generators vs lists. (x for x in range(10))[0] raises TypeError: 'generator' object is not subscriptable, not IndexError. Tutorials that present lst[0] and gen[0] as equivalent send readers chasing the wrong error type when the fix is list(gen)[0] or next(gen).

Frequently Asked Questions

Why is the last valid index len(items) - 1 and not len(items)?

Python (like C, Java, JavaScript) uses zero-based indexing. The first element is at index 0, so a list of 5 elements has valid indices 0, 1, 2, 3, 4. Index 5 is one past the end. The range(len(items)) idiom works because range(5) produces 0 through 4, not 0 through 5.

Why does if items work the same as if len(items) > 0?

Python considers empty sequences falsy. bool([]) is False; bool([1]) is True. The same applies to empty strings, empty dicts, empty sets, and zero numbers. if items: is preferred because it works uniformly across sequence types and is shorter; if len(items) > 0 is verbose and only works for things that have __len__.

What is the difference between IndexError and KeyError?

IndexError is for integer index access into a sequence (list, tuple, string) where the index is out of range. KeyError is for key access into a mapping (dict) where the key does not exist. The fix differs: bounds check + slice for index errors; .get() with default or in check for key errors. They look similar in user code but are not interchangeable.

Why does my list comprehension hit IndexError?

Either the list you are iterating over is empty (and you index inside the comprehension), or you index into a nested sub-list that has fewer elements than expected. The traceback line number is the comprehension, but the bad index is inside it. Refactor the comprehension into an explicit for loop temporarily to bisect which element triggers the bug.

Should I use try / except IndexError or explicit length check?

Both have a place. Use if items: or if len(items) > N when checking is normal flow (most cases). Use try / except IndexError only when the empty-or-short case is genuinely exceptional and the rest of the function depends on the index. EAFP (Easier to Ask Forgiveness than Permission) is Pythonic, but does not justify wrapping every subscript in a try.

How do I get the first / last element safely?

For the first: items[0] if items else None, or next(iter(items), None) (works on any iterable). For the last: items[-1] if items else None. For first and last together: items[0], items[-1] if items else (None, None). These guard the empty-list case explicitly without try/except.

Check for protobuf and msgpack deserialization. Binary deserialization formats often produce repeated fields as Python lists. A protobuf repeated field that was empty in the wire format gives you [], not the expected list of items. Code written assuming “this field always has at least one element” breaks the first time the producer sends an empty message.

Check for race conditions on shared mutable state. In a Flask or FastAPI app with a module-level list, two requests can both check if my_list: (both see True), then both call my_list.pop(). The second pop hits an empty list. Use a threading.Lock, switch to collections.deque with appropriate locking, or move state to Redis with atomic operations.

If the error is about dictionary keys rather than list indices, see Fix: Python KeyError. If the value is None and you are trying to subscript it, see Fix: TypeError: ‘NoneType’ object is not subscriptable.

For similar issues with missing attributes on None values, see Fix: AttributeError: ‘NoneType’ has no attribute.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles