Topic 4.8
Nested Data: Lists of Dicts and Friends
In one line
Real data is nested: a list of dictionaries whose values are lists or more dictionaries. This topic shows how to read, search, update and print such JSON-shaped data safely, which is the shape of every web API and AI model response.
Think of it like this
A school. The school has a list of classes; each class has a name and a list of students; each student has a name and marks. To find one mark you walk down: school, class, student, mark. Nested data is a container inside a container, and you reach the inner value by chaining brackets.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Nested data
- A container (list, dictionary) that holds other containers.
- Record
- One entry with several named fields, often one dictionary in a list.
- JSON
- A text format for nested data using objects (like dictionaries), arrays (like lists), strings, numbers, true, false and null.
- Chained access
- Reading a value through several steps:
data["a"][0]["b"]. - Default value
- A fallback returned when a key is missing, such as the second argument to
get. - pprint
- A standard-library module that prints nested data in a readable, indented way.
- Group by
- Collecting records that share a value (like the same tag) into one list per value.
- In place
- Changing the existing object instead of building a new one.
Step by step
01A list of dictionaries is a table
Each dictionary is a row and its keys are the column names. Looping through the list visits each row in order. Inside the loop you read fields by key.
inventory = [
{"name": "pen", "price": 10, "stock": 100, "tags": ["stationery", "blue"]},
{"name": "notebook", "price": 45, "stock": 0, "tags": ["stationery"]},
{"name": "bag", "price": 500, "stock": 7, "tags": ["school"]},
]
for item in inventory:
print(item["name"], item["price"])02Chain brackets one step at a time
To read the second tag of the first item: inventory[0] is a dictionary, ["tags"] is a list, [1] is a string. At every step, ask: what type am I holding now, and what operation does it support?
print(inventory[0]) # the first dict
print(inventory[0]["tags"]) # its list of tags
print(inventory[0]["tags"][1]) # 'blue'03Search, total and filter
Totals use a loop and an accumulator, a variable that starts at 0 and collects a running total. Filters use a comprehension (Topic 4.7). Finding one record by name is a loop with a break when you find it, or a dictionary keyed by name if you search often.
total = 0
for item in inventory:
total += item["price"] * item["stock"]
out_of_stock = [i["name"] for i in inventory if i["stock"] == 0]
found = None
for item in inventory:
if item["name"] == "bag":
found = item
break04Update in place
Because found points at the very same dictionary that is inside the list, changing it changes the list's content. No copy was made. That is convenient and also the reason to be careful.
found["stock"] -= 1
inventory[1]["tags"].append("sale")
inventory.append({"name": "pencil", "price": 5, "stock": 50, "tags": []})05Group records with setdefault
Group by tag: for each tag, keep a list of item names. setdefault(tag, []) returns the existing list or creates an empty one, then we append. (Topic 4.10 shows defaultdict, which does this with less code.)
by_tag = {}
for item in inventory:
for tag in item["tags"]:
by_tag.setdefault(tag, []).append(item["name"])
print(by_tag)06Safe access with get
Optional data should never crash your program. get with an empty dictionary as the default lets you chain more get calls safely. For a list, test it for emptiness before reading position 0. The trick data.get("user") or {} works because or gives back its first value that counts as true (Topic 1.8): None counts as false, so or falls through to {}.
data = {"user": None}
name = (data.get("user") or {}).get("name", "unknown")
print(name) # unknown
choices = []
first = choices[0] if choices else None
print(first) # None07What an AI response looks like
A chat model's reply is nested data: a dictionary with a list of choices, each holding a message dictionary. The exact keys depend on the provider, but the shape (dictionaries inside lists inside dictionaries) is always like this. Phase 16 calls real APIs; here we only read a hand-made response. The usage part counts tokens: the small pieces of text (often a word or part of a word) that a model reads and writes. prompt_tokens counts the ones you sent and completion_tokens the ones it wrote back.
response = {
"model": "demo-model",
"choices": [
{"message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 12, "completion_tokens": 3},
}
print(response["choices"][0]["message"]["content"])Try it yourself
- 1
Add a field
Add
"discount": 10to the notebook only. Then change the total loop to subtract the discount only when the key exists. (Hint:item.get("discount", 0).) - 2
Break the chain on purpose
In the second example, change
response.get("error", {})toresponse["error"]and run it. Read the error, then putgetback.
Code & diagrams
Change a stock number and see how the total and the out-of-stock list change. The first line, `import pprint`, loads the pretty-printing module (Topic 6.1 teaches import).
Expected output
stock value: 4500
out of stock: ['notebook']
by tag: {'stationery': ['pen', 'notebook'], 'blue': ['pen'], 'sale': ['notebook'], 'school': ['bag']}
{'name': 'bag', 'price': 500, 'stock': 6, 'tags': ['school']}`import json` loads Python's toolbox for JSON (Topic 6.1 teaches import). `json.dumps(x, indent=2)` turns nested data into indented JSON text.
Expected output
reply: Hello!
total tokens: 15
cached tokens: not reported
error: none
first choice of an empty reply: None
{
"message": {
"role": "assistant",
"content": "Hello!"
},
"finish_reason": "stop"
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Skip a level in the chain
Forget that choices is a list and index it with a key.
response = {"choices": [{"message": {"content": "Hi"}}]}
print(response["choices"]["message"])Break #2
Chain through a missing value
Go one level deeper into a value that is None.
data = {"user": None}
print(data["user"]["name"])Myth vs fact
Myth
Nested data needs special tools.
Fact
It is ordinary lists and dictionaries inside each other. Every step is a normal lookup.
Myth
JSON and Python dictionaries are the same thing.
Fact
JSON is text; a dictionary is an object in memory. They map onto each other, but JSON allows only strings as keys, and has null, true and false instead of None, True and False.
Myth
Missing keys are rare, so square brackets are fine everywhere.
Fact
Data from outside your program (files, users, APIs, AI models) is often incomplete. Use get and checks for anything optional.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A long chain of
[]hides which step failed in the traceback. For code that reads external data, validate the shape once at the boundary, with a Pydantic model (Topic 15.4) or a dataclass, and then use attribute access inside your program. - ▸
Searching a list of dicts by a field is O(n). If you search by
"id"repeatedly, build an index once:by_id = {row["id"]: row for row in rows}. Now each lookup is O(1), andby_id[7]is the same dictionary object that sits inrows(no copy). - ▸
A JSON round trip is not lossless.
json.dumps({1: "a"})writes the key as the string"1", sojson.loadsgives back{'1': 'a'}; a tuple comes back as a list; and a duplicate key in the text silently keeps the last value. Python'sjsonalso writesNaNandInfinity, which are not valid JSON, and many other parsers reject them. Likewise, merging config withdefaults | overridesis shallow (Topic 4.4): a nested"db"dictionary inoverridesreplaces the whole default"db"dictionary, so layered settings need a recursive merge. - ▸
Deeply nested updates are easy to get subtly wrong when the same inner object is shared (Topic 4.9). Treat data you received as read-only and build a new structure for your result when possible.
- ▸
Structured outputs from language models are nested JSON that you must validate, because a model can omit a field or invent one. Treat model output like user input: defensive reads, schema checks, and a retry path (Phase 16).
Remember this
- 1
The most common shape is a list of dictionaries, one dictionary per record:
[{"name": "pen", "price": 10}, {"name": "bag", "price": 500}]. It looks like a table: each dictionary is a row, each key is a column. You loop over the list and read keys from each row. This is the shape of database results, CSV rows and API lists. - 2
You can nest as deep as you like, and each step in the chain is a normal lookup:
data["choices"][0]["message"]["content"]means key"choices", then index 0, then key"message", then key"content". Reading such a chain left to right and saying what type each step returns is the quickest way to understand it. - 3
JSON (JavaScript Object Notation) is a text format for nested data, and Python maps onto it directly: JSON objects become dictionaries, arrays become lists, strings and numbers stay as they are,
true/false/nullbecomeTrue/False/None. So when a web service or an AI model API replies, you get a nested structure of dictionaries and lists. Reading and writing JSON files is covered in Topic 7.7. - 4
Real data is messy: a key may be missing, a value may be
None, a list may be empty. A long chain with square brackets raisesKeyError,IndexErrororTypeErrorthe moment one link is missing. Use.get(key, default)for optional keys, checkif items:before takingitems[0], and treatNoneexplicitly. A chain such asdata.get("user", {}).get("name", "unknown")returns a default instead of crashing. - 5
To update nested values, find the inner container and change it:
inventory[0]["stock"] -= 1,inventory[1]["tags"].append("sale"). Inner objects are changed in place, and any other name pointing at the same inner object sees the change. Copying nested data safely is the subject of Topic 4.9. - 6
Big nested data prints as one long, hard-to-read line.
pprint("pretty print") is a toolbox that comes with Python (a module). It prints nested data over several lines with indentation:pprint.pprint(data, sort_dicts=False). You load a module withimport pprint; Topic 6.1 teachesimport, so copy that line as it is for now. In AI work you will do this all the time to look at a model response, andjson.dumps(data, indent=2)is another common way.
Explain it without notes
How do you read the second tag of the first item in a list of dictionaries?
How do you safely read a value that may be missing, several levels deep?
How does JSON map onto Python types?
Why can changing found["stock"] change the list?
Practice
Given students = [{"name": "Asha", "marks": [80, 90]}, {"name": "Ravi", "marks": [70, 60, 65]}], print each student's name and average mark.
From the same data, build a dictionary mapping each name to the highest mark.
Given config = {"db": {"host": "localhost"}}, print the port with a default of 5432 without raising an error.
Trade-offs
- ↔
Flexible but unchecked. Dictionaries accept any keys, so typos and missing fields appear only when the code runs. A typed model (dataclass, Pydantic) costs a few lines and catches them early.
- ↔
Nested structure matches the data but is slow to search. A list of dicts is easy to read and to send as JSON, but finding by a field is a scan. Add an index dictionary when you search often.
Done when you can
Done when you can read and write values in a list of dictionaries, including a list inside a dictionary.
Done when you can total, filter, search and group records in a loop.
Done when you can access optional or missing data with
getand checks instead of crashing.Done when you can say how JSON types map to Python types.
Done when you can print nested data readably with
pprintorjson.dumps(..., indent=2).