Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Beginner+100 XP

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.

40 min August 2026
// Part 01 — Why This Module Exists

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.

A shape you will see constantly — a list of dicts
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 like
The inverse shape — a dict of lists
employees_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 11

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

// Part 02 — Safe Access Patterns

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.

Naive chained access — one missing field breaks everything
user = {
    "name": "Maria Gomez",
    "address": {
        "city": "Portland",
        "state": "OR",
    },
}

print(user["address"]["zip"])
# KeyError: 'zip' — this key was simply never provided for this user

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

Safe chained access with .get()
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
⚠️ Important
The fallback default at each intermediate step must itself support the next call. .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.

Guarding list access manually
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 None
// Part 03 — A Realistic Worked Example

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

The dataset — a list of orders, each order nested several levels deep
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.

Computing each order's total — combining a comprehension with a nested field
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.00

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

// Part 04 — Sorting Lists of Dicts

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.

Sorting orders by total value, using a lambda as the key
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.0

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

operator.itemgetter — the idiomatic shortcut for sorting by a dict key
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"])
🎯 Pro Tip
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.
// Part 05 — Aggregating Over Nested Structures

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.

Grouping order totals by customer
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 combined
Counting how many units of each SKU were sold, across every order
unit_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}
Grouping full order objects by state — the dict-of-lists shape from Part 01
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.

// Part 06 — Flattening Nested Structures

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.

Flattening orders into one flat row per line item
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.

As a nested comprehension — the compact version, from Module 12's Part 06
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.
// Part 07 — Deep Nesting and Where to Draw the Line

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?

A realistic, genuinely deep structure — the kind a real API sometimes hands you
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.

Normalize once, at the boundary — everything downstream stays simple
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.
🎯 Pro Tip
This is a genuinely important professional habit, not just a stylistic preference: the code that talks directly to a messy external structure (an API response, a legacy database export) should be small, isolated, and defensive (using .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.
// Part 08 — Real World
💼 What This Looks Like at Work

A Minneapolis Retailer's Broken Nightly Report

Scenario — Retail company, Minneapolis · Production incident

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.

The line that crashed
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.

The fix — safe access, with an explicit fallback bucket
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.

// Part 09 — Misconceptions

Four Misconceptions About Nested Data

✕ ""If the data worked fine in testing, chained [] access is safe enough for production""
Test data is very often more complete and consistent than real-world data. As the Real World example shows, a field that is always present in every historical test record can still be missing from a small fraction of real production records — and a naive [] chain crashes the entire job the first time that happens, rather than degrading gracefully.
✕ ""Deeply nested data should be worked with in its original nested shape throughout the codebase""
The professional pattern is the opposite: normalize messy or deeply nested external data into a clean, flat shape in one isolated place, as early as possible, and let the rest of the codebase depend only on that flat shape. This limits how much code needs to change when the external structure shifts.
✕ ""sorted() sorts a list of dicts by some obvious default order""
There is no default ordering for a dict — sorted() on a list of dicts without a key= argument raises a TypeError, since Python has no way to know which field to compare. You must always supply key= (a function or operator.itemgetter) telling it exactly which field to sort by.
✕ ""Flattening nested data just means removing the nesting — it's a lossless, mechanical step""
Flattening a one-to-many structure (like orders containing multiple items) genuinely expands the row count — each nested child becomes its own row, with the parent's fields repeated on every row. It is not a 1:1 transformation, and code downstream that assumes "one row per order" after flattening will double-count anything with more than one item.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

Given a list of dicts, how would you safely access a deeply nested field that might not exist?
Chain .get() calls instead of [] at every level that might be missing, and make sure each intermediate default is a container that supports the next .get() call — typically an empty dict {}. For example: user.get("address", {}).get("zip", "unknown"). Chaining plain [] would raise a KeyError the moment any key in the chain is missing; naive .get() without an intermediate default risks an AttributeError on None instead.
How do you sort a list of dicts by a specific field?
Use sorted() with a key= argument — either a lambda (sorted(records, key=lambda r: r["field"])) or, more idiomatically for a straightforward field lookup, operator.itemgetter (sorted(records, key=itemgetter("field"))). itemgetter also accepts multiple field names for a multi-level sort, similar to an SQL ORDER BY with several columns.
How would you group a list of records by a field and sum another field within each group?
Use collections.defaultdict with float or int as the factory, loop over the records once, and accumulate into the defaultdict keyed by the grouping field: totals = defaultdict(float); for r in records: totals[r["category"]] += r["amount"]. This is the standard pattern for aggregation before reaching for a heavier tool like pandas.
What does "flattening" nested data mean, and why does row count often change?
Flattening converts a nested structure (like a list of orders, each containing a nested list of line items) into a flat list with one row per leaf record, repeating the parent's fields on each row. Because a single parent can contain multiple children, flattening is a genuine one-to-many expansion — the flat row count typically exceeds the number of top-level records, not equal to it.
Why is it a good practice to normalize deeply nested external data into a flat structure at the boundary of your program, rather than working with the nested shape everywhere?
It isolates the fragile, defensive access code (chained .get() calls, handling missing fields) into one place. Everything downstream can then rely on a clean, predictable, flat structure. When the external data source changes its shape — which real APIs do — only that one boundary function needs updating, rather than every place in the codebase that reached directly into the nested structure.
// Common Mistakes

Nested Data Mistakes Beginners Make Constantly

Chaining [] several levels deep against data of uncertain shape
This works flawlessly right up until one record somewhere is missing a field, at which point the entire operation crashes with a KeyError or IndexError. Chain .get() with sensible intermediate defaults instead, whenever the data did not come from a source you fully control.
Forgetting that sorted() needs a key= for a list of dicts
sorted(records) on a list of dicts raises a TypeError: '<' not supported between instances of 'dict' and 'dict' — Python has no default way to compare two dicts for ordering. Always supply key=.
Assuming a flattened list has the same number of rows as the original nested list
As covered in the Misconceptions section, flattening a one-to-many nested structure expands the row count. Code that assumes len(flattened) == len(original) will silently miscount whenever any record has more than one nested child.
Mutating a nested dict or list shared between two variables
Just like the shallow-copy issue from Module 11, nested mutable structures are easy to accidentally share and mutate through more than one reference. If you need a fully independent nested copy, use copy.deepcopy() rather than a plain .copy() or list()/dict() wrap, which only copies the top level.
Threading deep chained access through many different functions across the codebase
Every function that reaches order["customer"]["address"]["state"] directly is a function that breaks the next time that API changes shape. Normalize the structure once, near where the data enters your program, and pass the clean, flat result to everything downstream.
// Error Library

Errors You Will Hit With Nested Data — And Exactly Why

KeyError: 'state'
Cause: Chained [] access hit a dict, somewhere in a nested structure, that was missing an expected key on this particular record — exactly the Real World scenario above.
Fix: Chain .get() with intermediate defaults instead of []: order.get("customer", {}).get("state", "UNKNOWN"). Never assume every record in real data has every field that appeared in your test data.
AttributeError: 'NoneType' object has no attribute 'get'
Cause: A .get() call's intermediate default was omitted (or was None), so the next .get() in the chain was called on None once the first key was missing.
Fix: Always supply an empty-container default ({} for a dict, [] for a list) at every intermediate .get() step in a chain, not just at the final one.
IndexError: list index out of range
Cause: Indexing into a nested list at a position it does not actually have — for example, assuming every order has at least 3 items when some have only 1.
Fix: Check len() before indexing, or use a comprehension/loop to iterate the list rather than assuming a fixed number of positions exist.
TypeError: '<' not supported between instances of 'dict' and 'dict'
Cause: Calling sorted() (or max()/min()) on a list of dicts without a key= argument — Python has no built-in way to compare two dicts for ordering.
Fix: Always pass key= — a lambda or operator.itemgetter naming exactly which field to sort or compare by.
TypeError: 'int' object is not subscriptable (or similar, mid-chain)
Cause: A chained access assumed a nested value was a dict or list at some level, but that record actually had a plain value (or None) there instead — a genuine shape inconsistency in the source data.
Fix: Validate or normalize the data shape at the boundary (Part 07) rather than assuming every record is perfectly uniform; log or skip records that do not match the expected shape instead of letting the whole job crash.

🎯 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 Dive
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...