Nested data: dictionaries inside lists inside dictionaries
What real data looks like
Anything that arrives from an API, a config file or a database export has depth. Here is the shape of a chat model's reply, trimmed:
response = {
"id": "chatcmpl-9f2",
"model": "small-v1",
"usage": {"prompt_tokens": 41, "completion_tokens": 88},
"choices": [
{"index": 0,
"message": {"role": "assistant", "content": "Delhi is the capital."},
"finish_reason": "stop"}
],
}Getting the text out is one expression, read left to right:
text = response["choices"][0]["message"]["content"]Take choices; it is a list; take the first item; it is a dictionary; take message; take content. Every navigation into nested data is that sentence. If you cannot say the sentence, you do not yet know the shape, and the next section is how to find out.
Look before you index
Do not guess. Print the structure:
print(type(response), list(response.keys()))
print(type(response["choices"]), len(response["choices"]))For anything bigger, the standard library formats it readably:
import json
print(json.dumps(response, indent=2)[:2000])json.dumps with an indent is better than pprint for API data because it shows you the thing as the server sent it, and truncating to the first 2,000 characters keeps a 10 MB response from filling the terminal.
The two error messages, and what each one means
TypeError: list indices must be integers or slices, not strYou used a string key on a list. You are one level too shallow — there is a list where you thought there was a dictionary, and you have forgotten a [0].
TypeError: string indices must be integersYou indexed into a string with a name. You are one level too deep — you already reached the text and kept going.
Those two messages between them account for most of the confusion in handling API responses, and each tells you exactly which direction you are wrong in.
Reading defensively, without hiding the problem
Chained get calls survive missing keys:
text = (response.get("choices") or [{}])[0].get("message", {}).get("content")That is safe and nearly unreadable, and it converts a clear failure into a silent None. Prefer a small function that says what went wrong:
def extract_text(response):
choices = response.get("choices")
if not choices:
raise ValueError(f"no choices in response: {list(response)}")
return choices[0]["message"]["content"]Now a change in the API gives you a message naming the keys that were there, which is the information you need at 2 a.m. The defensive one-liner gives you NoneType errors somewhere else entirely.
Walking a level
The common shapes are all loops over one level:
for choice in response["choices"]:
print(choice["message"]["content"])
names = [u["name"] for u in payload["data"]["users"]]
by_id = {u["id"]: u for u in payload["data"]["users"]}That last one — turning a list of records into a dictionary keyed by id — is worth remembering. It converts every later lookup from a scan into an instant one, and it is the same idea as the set lesson.
Depth you do not know in advance
For a tree of unknown depth, a function that calls itself:
def find_key(data, wanted):
if isinstance(data, dict):
for k, v in data.items():
if k == wanted:
yield v
yield from find_key(v, wanted)
elif isinstance(data, list):
for item in data:
yield from find_key(item, wanted)
list(find_key(response, "content")) # ['Delhi is the capital.']Eleven lines that will find every occurrence of a key anywhere in any JSON structure. Keep it; you will use it for exploring unfamiliar API responses more often than you expect.
Mutating nested data changes the shared thing
Everything from the copies lesson applies with more force here. config["limits"] handed to a function is the same dictionary the caller holds, and a function that adds a key to it has changed the caller's config. If a function must not modify what it is given, take a deepcopy at the top, or build and return a new structure — and say which one you did in the docstring.
Try this now
Save the response above to a file with json.dumps, read it back, and write a function returning (model, content, total_tokens) where the total is the sum of the two numbers in usage. Then delete the message key and confirm your function fails with a message you would be glad to see.
The one thing to keep
Navigating nested data is one sentence read left to right, and the two index TypeErrors tell you whether you are one level too shallow or one level too deep.
Before you move on
Code that has worked for months against an API starts raising `TypeError: list indices must be integers or slices, not str` on the line `data["results"]["items"]`. What does the message actually tell you?
Pick the one you would defend. Nobody sees your answer.