Nested Data Structures
Lists of dicts, dicts of lists, and the real-world JSON-shaped data you will actually work with — safe access, flattening, sorting, and aggregation.
Real Data Is Never Flat
Every module so far in Phase 2 has treated lists and dicts mostly in isolation — a list of numbers, a dict of a single employee's fields. Real data almost never looks like that. Open the response from any REST API, read a JSON config file, or inspect a database query result loaded into Python, and you will find lists containing dicts, dicts containing lists, and several levels of that nested inside each other. This module does not introduce any new syntax — it is entirely about combining what Module 11 (dicts) and Module 12 (comprehensions) already taught you to work confidently with the shapes data actually arrives in.
employees = [
{"name": "Priya Nair", "department": "Engineering", "salary": 118000},
{"name": "Wei Zhang", "department": "Engineering", "salary": 121000},
{"name": "Alex Torres", "department": "Sales", "salary": 95000},
]
# This is exactly what a database query, or a JSON API response, typically looks likeemployees_by_department = {
"Engineering": ["Priya Nair", "Wei Zhang"],
"Sales": ["Alex Torres"],
}
# The exact output shape you'd get from grouping the list above by department —
# using the defaultdict pattern from Module 11These two shapes — a list of dicts, and a dict of lists — cover the overwhelming majority of real-world structured data you will handle in Python. Learning to move confidently between them, and to safely reach into them several levels deep, is one of the most immediately useful practical skills in this entire track.
The KeyError / IndexError Risk of Naive Chained Access
The moment you nest a few levels deep, a single naive chain of [] lookups becomes fragile — any missing key or short list anywhere along the chain raises an exception and crashes the whole operation, even if the rest of the structure is perfectly fine.
user = {
"name": "Maria Gomez",
"address": {
"city": "Portland",
"state": "OR",
},
}
print(user["address"]["zip"])
# KeyError: 'zip' — this key was simply never provided for this userRecall .get() from Module 11 — the same tool applies here, chained the same way the brackets were chained, just swapping [] for .get() at each level that might be missing.
zip_code = user.get("address", {}).get("zip", "unknown")
print(zip_code) # "unknown" — no crash
# Read this right to left in terms of what it protects against:
# .get("zip", "unknown") -> if "zip" is missing, use "unknown"
# .get("address", {}) -> if "address" itself is missing, fall back to an empty dict,
# so the next .get() has something safe to call itself on.get("address", ) defaults to an empty dict — not None — specifically because the next .get() in the chain needs something dict-like to call. user.get("address").get("zip") without that intermediate default still crashes with AttributeError: 'NoneType' object has no attribute 'get' the moment "address" is missing, since .get() on a missing key returns None by default, and None has no .get() method of its own.Indexing into nested lists carries the same risk
The list equivalent of a missing dict key is a list that is shorter than expected — indexing past its end raises IndexError rather than returning a default, since lists have no built-in .get()-style method.
order = {"items": [{"sku": "A1"}, {"sku": "B2"}]}
# Naive — crashes if "items" has fewer than 3 entries
third_item = order["items"][2] # IndexError
# Guarded
items = order.get("items", [])
third_item = items[2] if len(items) > 2 else NoneModeling US E-Commerce Orders — Nested Customer Info and Line Items
This is the shape of data you will meet constantly in real work — a list of orders, each with nested customer details and a nested list of line items. Every technique in this module gets exercised against this one structure, so it is worth reading closely.
orders = [
{
"order_id": "ORD-1001",
"customer": {"name": "Maria Gomez", "city": "Portland", "state": "OR"},
"items": [
{"sku": "MUG-01", "qty": 2, "price": 12.00},
{"sku": "SHIRT-04", "qty": 1, "price": 28.00},
],
},
{
"order_id": "ORD-1002",
"customer": {"name": "James Reilly", "city": "Boston", "state": "MA"},
"items": [
{"sku": "MUG-01", "qty": 1, "price": 12.00},
],
},
{
"order_id": "ORD-1003",
"customer": {"name": "Maria Gomez", "city": "Portland", "state": "OR"},
"items": [
{"sku": "HAT-02", "qty": 3, "price": 18.00},
{"sku": "MUG-01", "qty": 1, "price": 12.00},
],
},
]Every order has exactly the shape you would get back from a real order-management API: top-level fields, a nested customer dict, and a nested items list of dicts. Nothing about this is contrived — this is genuinely what e-commerce, billing, and logistics data looks like in production.
for order in orders:
total = sum(item["qty"] * item["price"] for item in order["items"])
print(f"{order['order_id']}: ${total:.2f}")
# ORD-1001: $52.00
# ORD-1002: $12.00
# ORD-1003: $66.00This line does real work in a single expression: sum(...) consumes a generator expression (Module 12, Part 08) that reaches into each item's nested qty and price fields, multiplies them, and totals the result — no intermediate list ever gets built, since the total is the only thing needed.
sorted() with key= and operator.itemgetter
Sorting a plain list of numbers or strings just works — sorted(numbers). Sorting a list of dicts requires telling Python which field to sort by, since there is no single obvious ordering for a dict. The key= argument takes a function that, given one element, returns the value to sort by.
def order_total(order):
return sum(item["qty"] * item["price"] for item in order["items"])
orders_by_total = sorted(orders, key=order_total, reverse=True)
for o in orders_by_total:
print(o["order_id"], order_total(o))
# ORD-1003 66.0
# ORD-1001 52.0
# ORD-1002 12.0For the common, simpler case of sorting by a single existing dict key rather than a computed value, operator.itemgetter is the idiomatic, slightly faster alternative to a lambda — it exists specifically for this purpose and is worth knowing, since you will see it in real codebases and interview answers.
from operator import itemgetter
customers_flat = [o["customer"] for o in orders]
by_city = sorted(customers_flat, key=itemgetter("city"))
for c in by_city:
print(c["city"], c["name"])
# Boston James Reilly
# Portland Maria Gomez
# Portland Maria Gomez
# Equivalent lambda, for comparison:
by_city = sorted(customers_flat, key=lambda c: c["city"])itemgetter can also take multiple field names for a multi-level sort: itemgetter("state", "city") sorts by state first, then by city within each state — exactly like an ORDER BY with multiple columns in SQL. This is the version worth reaching for once a sort needs more than one key.Sums, Counts, and Grouping — Combining Module 11 and Module 12
Aggregation — computing totals, counts, or groups from a list of nested records — is the single most common thing you will actually do with data shaped like the orders list above. It combines exactly two tools you already have: defaultdict from Module 11 to group, and a comprehension or generator expression from Module 12 to compute.
from collections import defaultdict
totals_by_customer = defaultdict(float)
for order in orders:
name = order["customer"]["name"]
order_total = sum(item["qty"] * item["price"] for item in order["items"])
totals_by_customer[name] += order_total
print(dict(totals_by_customer))
# {"Maria Gomez": 118.0, "James Reilly": 12.0}
# Maria Gomez's two orders (ORD-1001 and ORD-1003) were automatically combinedunit_counts = defaultdict(int)
for order in orders:
for item in order["items"]:
unit_counts[item["sku"]] += item["qty"]
print(dict(unit_counts))
# {"MUG-01": 4, "SHIRT-04": 1, "HAT-02": 3}orders_by_state = defaultdict(list)
for order in orders:
state = order["customer"]["state"]
orders_by_state[state].append(order["order_id"])
print(dict(orders_by_state))
# {"OR": ["ORD-1001", "ORD-1003"], "MA": ["ORD-1002"]}Notice the pattern repeating across all three examples: pick the right defaultdict factory for what you are accumulating (float for a running total, int for a count, list for a group of items), loop once over the nested structure, and update the accumulator. This single pattern covers the vast majority of real reporting and analytics code you will write with Python before ever reaching pandas (Module 43), which exists largely to make exactly this kind of aggregation more concise at much larger scale.
Turning Nested Data Into a Flat List — For Reports, CSVs, and Tables
Nested data is efficient to store and easy to build incrementally, but reports, spreadsheets, and CSV files (Module 16) want flat rows — one row per record, no nesting. Flattening means walking the nested structure once and emitting one flat dict per "leaf" you actually care about.
flat_rows = []
for order in orders:
for item in order["items"]:
flat_rows.append({
"order_id": order["order_id"],
"customer_name": order["customer"]["name"],
"customer_city": order["customer"]["city"],
"sku": item["sku"],
"qty": item["qty"],
"price": item["price"],
})
for row in flat_rows[:2]:
print(row)
# {'order_id': 'ORD-1001', 'customer_name': 'Maria Gomez', 'customer_city': 'Portland', 'sku': 'MUG-01', 'qty': 2, 'price': 12.0}
# {'order_id': 'ORD-1001', 'customer_name': 'Maria Gomez', 'customer_city': 'Portland', 'sku': 'SHIRT-04', 'qty': 1, 'price': 28.0}Notice this is a genuine one-to-many expansion: three orders with a total of five line items between them become five flat rows, one per item, with the order- and customer-level fields repeated on each row. This exact shape — repeating parent fields across every child record — is precisely what a CSV export or a SQL join naturally produces, and it is the reason CSV and JSON so often need conversion in both directions.
flat_rows = [
{
"order_id": order["order_id"],
"customer_name": order["customer"]["name"],
"sku": item["sku"],
"qty": item["qty"],
}
for order in orders
for item in order["items"]
]
# Same two-for-clause flattening pattern from Module 12 — genuinely readable here,
# since there's exactly one level of nesting and no additional filter or ternary.When a Dict of Dicts of Lists of Dicts Is Too Much
Nothing in Python stops you from nesting dicts and lists five or six levels deep — a dict of customers, each with a list of orders, each with a nested dict of items, each with a nested dict of discounts... it is technically valid, and you will occasionally receive data shaped exactly like this from a third-party API you do not control. The question this module wants you to ask is: once you receive data this deep, should your own code keep working with it in that exact shape?
response = {
"data": {
"customers": [
{
"id": 501,
"orders": [
{"id": "ORD-1001", "items": [{"sku": "MUG-01", "discounts": [{"code": "WELCOME10"}]}]}
],
}
]
}
}
# Reaching six levels deep for one value is technically possible...
first_discount_code = response["data"]["customers"][0]["orders"][0]["items"][0]["discounts"][0]["code"]
# ...but it is fragile, unreadable, and will be the first thing to break the next time the API
# response shape changes even slightly.The practical fix is the same one this whole module has been building toward: extract what you need into a flatter, purpose-built structure as early as possible — right where the data enters your program — rather than threading deep chained access through the rest of your codebase. Write one function that walks the nested API response once and returns a clean, flat list of the records your program actually needs; let every other function in your codebase work only with that flat, predictable shape.
def extract_discount_codes(api_response):
codes = []
for customer in api_response.get("data", {}).get("customers", []):
for order in customer.get("orders", []):
for item in order.get("items", []):
for discount in item.get("discounts", []):
codes.append(discount.get("code"))
return codes
# Every other function in the codebase now just works with a flat list of strings —
# no other function needs to know the original response was six levels deep..get() at every level, as shown above) — and everything else in your program should work with the clean, flat shape that function produces. When the external API changes its shape, you have exactly one function to fix, not every place in the codebase that happened to reach into the nested structure directly.A Minneapolis Retailer's Broken Nightly Report
A Minneapolis retailer's nightly job pulls order data from a fulfillment partner's API and emails a summary of revenue by state to the operations team every morning. It has run reliably for months. One Tuesday, the job crashes at 3 a.m. and no report goes out.
revenue_by_state = defaultdict(float)
for order in api_orders:
state = order["customer"]["state"]
total = sum(item["qty"] * item["price"] for item in order["items"])
revenue_by_state[state] += total
# KeyError: 'state'What the investigation finds
The fulfillment partner had shipped a change the day before: for a small number of orders placed through a new in-store kiosk, the customer object omitted state entirely when the customer checked out as a guest without providing a full address. Every order in the historical test data happened to include state, so the naive order["customer"]["state"] chain — exactly the fragile pattern from Part 02 — had simply never been exercised against a missing field until that one guest order came through in production.
The fix
The engineer rewrites the access using the safe .get() chaining pattern from Part 02, with an explicit fallback bucket for orders missing location data — so the report still runs completely, and the missing-data orders become visible as a line item instead of a silent crash.
revenue_by_state = defaultdict(float)
for order in api_orders:
state = order.get("customer", {}).get("state", "UNKNOWN")
total = sum(item["qty"] * item["price"] for item in order["items"])
revenue_by_state[state] += total
# "UNKNOWN" now shows up as its own line in the report — visible and actionable,
# instead of crashing the entire job over a handful of orders.The team also adds the normalize-at-the-boundary pattern from Part 07: a single parse_order() function that walks the raw API response once, fills in explicit defaults for every optional field, and hands the rest of the pipeline a clean, predictable structure — so the next time the partner's API shape shifts slightly, exactly one function needs to change, not every report that touches order data.
Four Misconceptions About Nested Data
5 Interview Questions — With Complete Answers
Nested Data Mistakes Beginners Make Constantly
Errors You Will Hit With Nested Data — And Exactly Why
🎯 Key Takeaways
- ✓Two shapes cover most real-world data: a list of dicts (rows of records) and a dict of lists (records grouped by key). Learn to move confidently between them.
- ✓Chain .get() with sensible intermediate defaults ({} for a dict, [] for a list) instead of chaining [] — real data is rarely as complete as your test data.
- ✓sorted() needs an explicit key= for a list of dicts — a lambda or, idiomatically, operator.itemgetter for sorting directly by one or more existing fields.
- ✓Aggregation (sums, counts, grouping) over nested data combines collections.defaultdict from Module 11 with a comprehension or generator expression from Module 12.
- ✓Flattening a one-to-many nested structure (like orders containing multiple items) genuinely expands the row count — it is not a lossless, row-preserving transformation.
- ✓Normalize deeply nested or messy external data into a clean, flat shape in one isolated place near where it enters your program — do not thread deep chained access through the rest of the codebase.
- ✓A missing field that never appeared in test data can still appear in production. Defensive access (.get() with defaults) is not paranoia — it is standard practice for any data you do not fully control.
What comes next
Module 14 goes back to strings — building directly on Module 04's foundations — to cover parsing messy real-world text, cleaning and normalising it, and the formatting tools that matter once you are producing output, not just consuming it.
Module 14 → String Manipulation Deep DiveDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.