Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~26 min· topic 8 of 11

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.

main.pywhole filepython
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"])
A list of dictionaries is a tablediagram
Rendering diagram…

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?

main.pywhole filepython
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.

main.pywhole filepython
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
        break

04Update 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.

main.pywhole filepython
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.)

main.pywhole filepython
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 {}.

main.pywhole filepython
data = {"user": None}
name = (data.get("user") or {}).get("name", "unknown")
print(name)            # unknown

choices = []
first = choices[0] if choices else None
print(first)           # None

07What 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.

main.pywhole filepython
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. 1

    Add a field

    Add "discount": 10 to the notebook only. Then change the total loop to subtract the discount only when the key exists. (Hint: item.get("discount", 0).)

  2. 2

    Break the chain on purpose

    In the second example, change response.get("error", {}) to response["error"] and run it. Read the error, then put get back.

Code & diagrams

A small inventory New tab

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).

Sign in to run this example in your browser.

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']}
Reading a model response safely New tab

`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.

Sign in to run this example in your browser.

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.

main.pywhole filepython
response = {"choices": [{"message": {"content": "Hi"}}]}
print(response["choices"]["message"])
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 2, in <module>
print(response["choices"]["message"])
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
TypeError: list indices must be integers or slices, not str

Break #2

Chain through a missing value

Go one level deeper into a value that is None.

main.pywhole filepython
data = {"user": None}
print(data["user"]["name"])
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 2, in <module>
print(data["user"]["name"])
~~~~~~~~~~~~^^^^^^^^
TypeError: 'NoneType' object is not subscriptable

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), and by_id[7] is the same dictionary object that sits in rows (no copy).

  • ▸

    A JSON round trip is not lossless. json.dumps({1: "a"}) writes the key as the string "1", so json.loads gives back {'1': 'a'}; a tuple comes back as a list; and a duplicate key in the text silently keeps the last value. Python's json also writes NaN and Infinity, which are not valid JSON, and many other parsers reject them. Likewise, merging config with defaults | overrides is shallow (Topic 4.4): a nested "db" dictionary in overrides replaces 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. 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. 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. 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/null become True/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. 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 raises KeyError, IndexError or TypeError the moment one link is missing. Use .get(key, default) for optional keys, check if items: before taking items[0], and treat None explicitly. A chain such as data.get("user", {}).get("name", "unknown") returns a default instead of crashing.

  5. 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. 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 with import pprint; Topic 6.1 teaches import, 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, and json.dumps(data, indent=2) is another common way.

Explain it without notes

01

How do you read the second tag of the first item in a list of dictionaries?

02

How do you safely read a value that may be missing, several levels deep?

03

How does JSON map onto Python types?

04

Why can changing found["stock"] change the list?

Practice

01

Given students = [{"name": "Asha", "marks": [80, 90]}, {"name": "Ravi", "marks": [70, 60, 65]}], print each student's name and average mark.

02

From the same data, build a dictionary mapping each name to the highest mark.

03

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 get and 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 pprint or json.dumps(..., indent=2).