KeyError means a lookup by key didn’t find the key. It comes from d[key], d.pop(key), del d[key], set.remove(item), os.environ[name], and from pandas when a column or index label is missing. The message is the repr() of the key that was looked up, nothing else:

Traceback (most recent call last):
  File "sync.py", line 14, in <module>
    owner = repo["owner"]["login"]
            ~~~~~~~~~~~~~^^^^^^^^^
KeyError: 'login'

Since Python 3.11, the ~~~^^^ markers under the line show which subscript failed. Here repo["owner"] worked and ["login"] didn’t. On older versions, a line with several lookups needs to be split up, or checked one key at a time.

Most KeyErrors are solved by reading that one line of output carefully, before touching the code.

TLDR

The message is the repr() of the missing key. Read it before reading the code:

  • KeyError: 1 / KeyError: '1': int vs str key, often after JSON. Fix: convert keys at the boundary.
  • KeyError: 'name\n', KeyError: ' email': hidden whitespace. Fix: .strip() the key or df.columns.str.strip().
  • KeyError: 0 in pandas: label lookup, not position. Fix: .iloc[0].
  • KeyError: 'DATABASE_URL': env var not set for this process. Fix: set it, or fail with a clear message.
  • any other key: key really missing. Fix: .get(key, default) only if a default is correct; otherwise raise a clear error.

print(sorted(d.keys())) shows what’s actually there.

Read the key in the message

Because the message is a repr(), it shows things that are invisible when you print the key normally.

Quotes tell you the type. KeyError: 'id' is a string; KeyError: 1 is an int; KeyError: (2, 3) is a tuple. If the dict has the key but with another type, the lookup fails.

Escapes show hidden characters. KeyError: 'report.csv\n' means the key has a trailing newline, typically from reading lines out of a file without .strip(). KeyError: ' email' (leading space) is a classic from CSV headers written as name, email.

Case and spelling are exact. 'userId', 'user_id', and 'UserID' are three different keys. When you can’t see the difference, print what is actually there:

print(sorted(data.keys()))

JSON turned your int keys into strings

JSON object keys are always strings. Any dict that goes through json.dumps/json.loads, a JSON file, an HTTP API, Redis, or a cache serializer comes back with string keys:

import json

scores = {1: "gold", 2: "silver"}
restored = json.loads(json.dumps(scores))

print(restored)     # {'1': 'gold', '2': 'silver'}
restored[1]         # KeyError: 1

This is the usual cause of KeyError: 1 and KeyError: '1' (the second one when the lookup key is a string and the dict was built with ints, for example from IDs parsed out of a URL). Pick one type and convert at the boundary:

restored = {int(k): v for k, v in json.loads(raw).items()}

pandas: KeyError: 0 and missing columns

In pandas, series[0] and df[0] are lookups by label, not position. After filtering, sorting, or loading data with a non-default index, the label 0 may not exist:

import pandas as pd

df = pd.DataFrame({"price": [5, 12, 30]})
expensive = df[df.price > 10]["price"]   # index is now [1, 2]

expensive[0]        # KeyError: 0
expensive.iloc[0]   # 12, the first row by position

Use .iloc[...] for positions and .loc[...] for labels, or call reset_index(drop=True) if you want positions and labels to match again.

A KeyError naming a column is almost always whitespace or case in the header. Check df.columns.tolist(); if you see ' email', clean the names once after loading:

df.columns = df.columns.str.strip()

Environment variables

os.environ["DATABASE_URL"] raises KeyError: 'DATABASE_URL' when the variable isn’t set in the process that runs your code. That process is not always your shell: cron jobs, systemd units, Docker containers, IDE run configurations, and sudo all start with a different environment. If the variable is required, fail with a clear message at startup:

import os

try:
    db_url = os.environ["DATABASE_URL"]
except KeyError:
    raise SystemExit("DATABASE_URL is not set") from None

If it’s optional, os.environ.get("LOG_LEVEL", "INFO") is fine.

Nested data from APIs

With nested JSON, the failing key might be any level. The 3.11+ markers point to the exact one; on older versions, check level by level. Chained .get() is a common workaround, but it has a gap:

data = {"user": None}

data.get("user", {}).get("name")
# AttributeError: 'NoneType' object has no attribute 'get'

The default {} is only used when the key is missing, not when its value is null in the JSON. If you need to handle both, use (data.get("user") or {}).get("name"), or validate the payload once with a schema library (pydantic, marshmallow) instead of guarding each lookup.

When .get() is the wrong fix

.get(key, default) makes the error disappear, which isn’t always what you want. If the key is required for the code to be correct, a default just moves the failure somewhere else: an order total of 0, an email sent to None. Use .get() when a missing key is a normal, expected case with a meaningful default. For required keys, let the KeyError surface, or raise an error that says which field was missing and from where:

try:
    order_id = payload["order_id"]
except KeyError:
    raise ValueError(f"webhook payload missing order_id: {payload!r}") from None

For counting and grouping, where the default is always the same, collections.defaultdict(int) or collections.Counter avoid the lookup problem entirely.

In Flask, request.form["field"] raises BadRequestKeyError, a KeyError subclass that turns into a 400 response. See Flask KeyError in request data for that case.

Reading the traceback

A KeyError raised deep inside a library (pandas, Django, a config loader) has many frames between your code and the lookup that failed. The line you care about is the last frame in your own files, where you passed the key in. Debugly’s stack trace formatter separates your frames from library frames in a pasted traceback, which helps when the KeyError comes from inside pandas’ indexing code. For reading tracebacks in general, see the Python traceback guide.