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'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 not1(int),'name 'has a trailing space,'Name'is not'name'. - Print the keys that are there:
print(sorted(d))orprint(d.keys()). - In chained lookups, the
~~~markers show which dictionary was indexed: above,payload["user"]lacks"email", notpayload. - If the key is optional, use
d.get(key, default). If it is required, keepd[key]and validate input earlier with a clear message. - For counting or grouping, use
collections.Counter,defaultdict(list)orsetdefaultinstead 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 spaceKeyError 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'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.5Pick 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']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"])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 keysOne 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'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}}'))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'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}"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:
passThis 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")$ python3 test_events.py
payload missing required fields: ['order_id']
all checks passed
$ mypy --strict events.py
Success: no issues found in 1 source fileInterview 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.