Ch. 9 · Python

KeyError in Python: Fix Missing Dictionary Keys Safely

Fix Python KeyError: 'x': d[key] vs .get, in checks, setdefault and defaultdict, JSON payload validation, pandas columns and odd key reprs.

~7 min readbeginnerupdated Oct 4, 2026

A dictionary lookup fails and Python prints the key it could not find (Python 3.14.7, path shortened; 3.12 and 3.13 print the same):

Traceback (most recent call last):
  File "ke1.py", line 3, in <module>
    print(payload["user"]["email"])
          ~~~~~~~~~~~~~~~^^^^^^^^^
KeyError: 'email'
Text

The dictionary exists, but it has no entry whose key equals 'email'. Unlike the NoneType errors, the object is fine; the key is the problem. The text after KeyError: is the repr() of the key you asked for, which turns out to be the most useful clue in the whole traceback.

Quick fix checklist

  • Read the key exactly as printed: '1' (string) is not 1 (int), 'name ' has a trailing space, 'Name' is not 'name'.
  • Print the keys that are there: print(sorted(d)) or print(d.keys()).
  • In chained lookups, the ~~~ markers show which dictionary was indexed: above, payload["user"] lacks "email", not payload.
  • If the key is optional, use d.get(key, default). If it is required, keep d[key] and validate input earlier with a clear message.
  • For counting or grouping, use collections.Counter, defaultdict(list) or setdefault instead of checking first.
  • For pandas, print df.columns.tolist() and strip whitespace from headers.

Before you start

You should know how dictionaries work and what JSON looks like once json.loads turns it into Python objects. The pandas example used pandas 3.0.6 in a virtual environment. If your keys are custom objects, read hashable dictionary keys as well, because a mutated or badly hashed key produces KeyError even when the key “is there”.

Why it happens

d[key] hashes key, jumps to the matching bucket and compares candidates with ==. If no stored key is equal and the class has no __missing__ method, it raises KeyError(key). Equality is exact and type-aware, with one quirk: numbers that compare equal are the same key, so {1: 'a'}[True] and {1: 'a'}[1.0] both return 'a'.

str(KeyError(k)) is repr(k), so the message shows the key the way Python would write it:

d[(3, 4)]        ->  KeyError: (3, 4)
d[1]             ->  KeyError: 1          # dict had '1', a string
d['']            ->  KeyError: ''         # an empty string slipped in
d['name ']       ->  KeyError: 'name '    # trailing space
Text

KeyError also comes from places that are dictionaries in disguise:

os.environ['STACKTRICK_MISSING_VAR']    ->  KeyError: 'STACKTRICK_MISSING_VAR'
set().remove('x')                       ->  KeyError: 'x'
{}.pop('x')                             ->  KeyError: 'x'
{}.popitem()                            ->  KeyError: 'popitem(): dictionary is empty'
'Hello {name}'.format(user='Ana')       ->  KeyError: 'name'
Text

The last one surprises people: str.format looks placeholders up by name, so a template field without a matching keyword argument is a KeyError.

Step-by-step walkthrough

Step 1: Read the key’s repr literally

Copy the key from the message and compare it with what you expected, character by character. A common case is a number that arrived as a string from JSON, a query string or a CSV, then is looked up as an int:

prices = {"1": 9.5}
prices[1]      # KeyError: 1
prices["1"]    # 9.5
python

Pick one representation at the boundary (convert once when parsing) instead of converting at every lookup.

Step 2: Inspect what the dictionary actually holds

Put breakpoint() before the failing line, or print the keys of the exact object being indexed:

print(sorted(payload["user"]))   # ['id', 'name']
python

For nested data, check each level: the markers under the failing line tell you which subscription failed, and the object to the left of the ^^^ is the one missing the key.

Step 3: Decide whether the key is required or optional

This decision, not the syntax, is the fix:

config = {"host": "localhost"}

host = config["host"]              # required: a KeyError here is a real bug
port = config.get("port", 5432)    # optional: documented default
if "tls" in config:                # optional, and the two cases differ
    enable_tls(config["tls"])
python

Two details matter with .get. Without a default it returns None, which only moves the crash to a later 'NoneType' object is not subscriptable. And config.get("port") or 5432 is not the same as a default: it also replaces a legitimate 0 or empty string.

Step 4: Remove the missing case for aggregation

When you build a dictionary up, the first time you see a key it is missing by definition. Let a tool handle that instead of writing if key not in d: d[key] = [] everywhere:

from collections import Counter, defaultdict

words = ["apple", "avocado", "banana"]

groups = defaultdict(list)
for word in words:
    groups[word[0]].append(word)
print(dict(groups))               # {'a': ['apple', 'avocado'], 'b': ['banana']}

index = {}
for word in words:
    index.setdefault(word[0], []).append(word)   # same result, plain dict

print(Counter("hello")["z"])      # 0, Counter returns 0 for missing keys
python

One caveat: reading a missing key from a defaultdict inserts it. After groups["z"], "z" in groups is True. Use groups.get("z") or in when you only want to look.

Step 5: pandas columns

pandas raises KeyError for a missing column label, often because of whitespace or case in a CSV header:

df = pd.read_csv(io.StringIO("Order ID,Amount \n1,9.5\n"))
print(df.columns.tolist())   # ['Order ID', 'Amount ']
df["Amount"]                 # KeyError: 'Amount'
python

The header has a trailing space. Normalise once after loading with df.columns = df.columns.str.strip(). Selecting several columns with one missing gives a different message, KeyError: "['b'] not in index", which lists the labels that were not found.

Worked scenario

A webhook handler for an e-commerce platform sends receipts:

import json


def handle(body: str) -> str:
    event = json.loads(body)
    email = event["customer"]["email"]
    return f"receipt for {event['order_id']} sent to {email}"


print(handle('{"order_id": "A-100", "customer": {"id": 7, "email": "ana@example.com"}}'))
print(handle('{"order_id": "A-101", "customer": {"id": 8}}'))
python
receipt for A-100 sent to ana@example.com
Traceback (most recent call last):
  ...
  File "webhook.py", line 6, in handle
    email = event["customer"]["email"]
            ~~~~~~~~~~~~~~~~~^^^^^^^^^
KeyError: 'email'
Text

Diagnosis. The markers show event["customer"] exists but has no "email". The provider’s documentation says guest checkouts omit email. So email is optional, while order_id and customer.id are required. The code treated everything as required, and the result was an unexplained 500 error for every guest order.

Fix. Describe the payload, validate the required fields once at the boundary, and handle the optional one explicitly:

import json
from typing import NotRequired, TypedDict, cast


class Customer(TypedDict):
    id: int
    email: NotRequired[str]


class OrderEvent(TypedDict):
    order_id: str
    customer: Customer


def parse_event(body: str) -> OrderEvent:
    data = json.loads(body)
    missing = [k for k in ("order_id", "customer") if k not in data]
    if missing:
        raise ValueError(f"payload missing required fields: {missing}")
    if "id" not in data["customer"]:
        raise ValueError("payload missing required field: customer.id")
    return cast(OrderEvent, data)


def handle(body: str) -> str:
    event = parse_event(body)
    email = event["customer"].get("email")
    if email is None:
        return f"order {event['order_id']}: guest checkout, no receipt sent"
    return f"receipt for {event['order_id']} sent to {email}"
python

TypedDict does no runtime checking (that is what parse_event is for), but it lets a type checker guard the optional key. pyright rejects event["customer"]["email"] with "email" is not a required key in "Customer", so access may result in runtime exception. For larger payloads, a validation library such as Pydantic replaces the hand-written checks.

Common mistake

The tempting fix is a broad try:

try:
    send_receipt(event)
except KeyError:
    pass
python

This swallows the guest-checkout case, but also every unrelated KeyError inside send_receipt and everything it calls, including real bugs. If you catch KeyError, keep the try body to the single lookup.

The opposite overcorrection is replacing every d[key] with d.get(key). Required data then flows on as None and fails later, further from the cause (see TypeError: ‘NoneType’ object is not subscriptable). A KeyError at the point where required data is missing is a feature.

Verify the behavior

Test the three payload shapes: complete, missing an optional field, missing a required field.

from events import handle

assert handle('{"order_id": "A-100", "customer": {"id": 7, "email": "ana@example.com"}}') == "receipt for A-100 sent to ana@example.com"
assert handle('{"order_id": "A-101", "customer": {"id": 8}}') == "order A-101: guest checkout, no receipt sent"
try:
    handle('{"customer": {"id": 9}}')
except ValueError as e:
    print(e)
else:
    raise AssertionError("expected ValueError")
print("all checks passed")
python
$ python3 test_events.py
payload missing required fields: ['order_id']
all checks passed
$ mypy --strict events.py
Success: no issues found in 1 source file
Text

Interview exercise

“Compare d[k], d.get(k, default), d.setdefault(k, []) and defaultdict(list). When would you pick each, and how does defaultdict work under the hood?”

Answer and reasoning

d[k] asserts the key must exist; a KeyError signals a bug or invalid input, which is what you want for required data. d.get(k, default) is for optional keys with a sensible fallback; it never modifies the dict, and the default expression is evaluated even when the key exists. d.setdefault(k, []) returns the existing value or inserts and returns the default, handy for one-off grouping on a plain dict, though it builds the default object on every call. defaultdict(list) calls its factory only when a key is missing and stores the result, which is the cleanest choice for counting and grouping loops.

Under the hood, dict.__getitem__ calls __missing__(key) on a miss if the subclass defines it; defaultdict implements __missing__ to call the factory and insert the value. That is also why only [] access triggers it: get and in do not call __missing__, so they never insert. A strong answer notes the side effect: reading a missing key from a defaultdict changes the dict.

Continue learning

Practise these trade-offs with the Python interview questions and the Python MCQs. Related notes: hashable dictionary keys, exception chaining for re-raising a KeyError as a clearer domain error, and TypeError: ‘NoneType’ object is not subscriptable. Official references: the KeyError exception, mapping types and dict methods, collections.defaultdict and pandas’ guide to indexing and selecting data.

More in Python

esc