1.2.26 · D4Introduction to Programming (Python)

Exercises — Nested data structures — list of dicts, dict of lists

3,090 words14 min readBack to topic

Before we climb, recall the one law from the parent parent note that everything below rests on:

Throughout, "LoD" = List of dicts, "DoL" = Dict of lists.

The picture below is that transpose law made concrete: the same dataset drawn twice, once as horizontal row-cards (LoD) and once as vertical column-lists (DoL). Trace the plum-shaded cell — it is Ravi's score, reached as [1][score] on the left and [score][1] on the right. Look at how the two coordinates literally swap places as your eye crosses the purple arrow: that swap is the whole idea, and every exercise on this page is a variation of it.

Figure — Nested data structures — list of dicts, dict of lists

Our running dataset (memorise the shape, not the numbers):

# List of dicts (LoD) — one dict = one row
people_lod = [
    {"name": "Asha",  "age": 20, "score": 88},
    {"name": "Ravi",  "age": 22, "score": 73},
    {"name": "Meera", "age": 21, "score": 95},
]
 
# Dict of lists (DoL) — one list = one column
people_dol = {
    "name":  ["Asha", "Ravi", "Meera"],
    "age":   [20, 22, 21],
    "score": [88, 73, 95],
}

Two Python facts we lean on repeatedly — recap them once here so you never have to leave the page:

Recall Recap: what a

comprehension is (and why we use it) A list comprehension [f(x) for x in seq] is shorthand for "build a new list by running f on every x in seq." It replaces the 3-line loop:

out = []
for x in seq:
    out.append(f(x))

Why we use it here: in a LoD, a column's values are scattered across separate dicts. A comprehension is the compact way to gather them — [row["score"] for row in people_lod] walks every row and pulls its score into one flat list. Add if cond at the end to keep only some rows. Full treatment in List Comprehensions.

Recall Recap:

mutable objects share identity (why one edit can hit two names) Lists and dicts are mutable — you can change their contents in place. Crucially, a variable does not hold a copy of the list; it holds a reference (a pointer) to one list object in memory. If two names point to the same list, editing through one is visible through the other:

a = []
b = a          # b points to the SAME list as a
a.append(1)
b              # [1]  — b changed too, they are one object

Why it matters here: several exercises accidentally make many dict keys share one list, so appending to one appears to append to all. The fix is always to create a fresh list. Full treatment in Mutable vs Immutable.


Level 1 — Recognition

Goal: tell the two shapes apart and read a single cell without running code.

L1.1 — Which shape is this?

Look at each snippet and say LoD or DoL, and name the outer container type.

A = [{"x": 1}, {"x": 2}]
B = {"x": [1, 2], "y": [3, 4]}
Recall Solution L1.1
  • A is a LoD: outer is [...] (a list), each element is a dict → list of dicts.
  • B is a DoL: outer is {...} (a dict), each value is a list → dict of lists. The giveaway is always the outermost bracket: [ ⇒ list ⇒ LoD, { ⇒ dict ⇒ DoL.

L1.2 — Read one cell (no code)

Using people_lod above, what does people_lod[1]["name"] evaluate to?

Recall Solution L1.2

Peel outer first. people_lod[1] is the second row (index counting starts at 0): {"name": "Ravi", "age": 22, "score": 73}. Then ["name"] reads that dict's name field. Answer: "Ravi".

L1.3 — Same cell, other shape

In people_dol, write the expression for the same cell (row 1, field name).

Recall Solution L1.3

Apply the transpose law — key first, then integer index: people_dol["name"][1]"Ravi". Compare to LoD's people_lod[1]["name"]: the two coordinates literally swapped order.


Level 2 — Application

Goal: run the standard operations — column math, updates, filtering.

L2.1 — Average score, both shapes

Compute the average score over the three people, once from people_lod and once from people_dol.

Recall Solution L2.1

LoD (scores are scattered across dicts → gather first with a comprehension):

total = sum(p["score"] for p in people_lod)   # 88 + 73 + 95 = 256
avg = total / len(people_lod)                 # 256 / 3

DoL (column already a flat list → sum directly):

avg = sum(people_dol["score"]) / len(people_dol["score"])   # 256 / 3

Both give . This is exactly why DoL wins column math: no gathering step. See List Comprehensions for the p["score"] for p in ... engine.

L2.2 — Update one field

Raise Ravi's score by 5 in each structure.

Recall Solution L2.2

LoD — you must find the row first (search by name), then edit its field:

for p in people_lod:
    if p["name"] == "Ravi":
        p["score"] += 5     # 73 → 78

DoL — if you already know the position (row index 1), edit directly:

people_dol["score"][1] += 5   # 73 → 78

New value in both: 78. (This mutates in place — see Mutable vs Immutable.)

L2.3 — Filter rows

From people_lod, build a new LoD of only people with score >= 85. Give the resulting name list.

Recall Solution L2.3
top = [p for p in people_lod if p["score"] >= 85]
names = [p["name"] for p in top]   # ["Asha", "Meera"]

Asha (88) and Meera (95) pass; Ravi (73) fails. Result names: ["Asha", "Meera"]. We built a new list rather than deleting in place — safer (see the L5 trap).


Level 3 — Analysis

Goal: reason about which shape a task prefers, and predict errors.

L3.1 — Predict the error

What happens if you run people_lod["name"] (note: LoD, not DoL)?

Recall Solution L3.1

people_lod is a list. A list can only be indexed by an integer (or a slice), never by a string. Python raises TypeError: "list indices must be integers or slices, not str." The fix is the transpose law: integer first → people_lod[0]["name"].

L3.2 — Which shape, and why?

For each task, name the better structure and give the one-line reason.

  1. Append a brand-new person record.
  2. Find the maximum age across everyone.
  3. Store 1 million rows that all share the same 3 keys, using least memory.
Recall Solution L3.2
  1. LoD — a record is a self-contained dict you just .append(...) to the list.
  2. DoL — the age column is already a flat list, so max(people_dol["age"]) works directly. (Here max = 22.)
  3. DoL — the key strings ("name", "age", "score") are stored once as dict keys, not repeated inside a million dicts. LoD would store the key strings a million times each. This is the shape Pandas DataFrame uses under the hood.

L3.3 — Count of matching rows

Using people_lod, how many people are strictly older than 20? Show the expression.

Recall Solution L3.3
count = sum(1 for p in people_lod if p["age"] > 20)   # Ravi(22), Meera(21) → 2

sum(1 for ...) counts the rows that pass. Answer: 2. (Asha is exactly 20, so > 20 excludes her.)


Level 4 — Synthesis

Goal: build one shape from the other, and construct new structures.

L4.1 — LoD → DoL (the transpose)

Convert this LoD to a DoL by hand, then in one line.

lod = [{"name": "Asha", "score": 88},
       {"name": "Ravi", "score": 73}]
Recall Solution L4.1

By hand: walk each key, collect that key's value from every row in order.

  • name column: ["Asha", "Ravi"]
  • score column: [88, 73] One-liner:
dol = {k: [row[k] for row in lod] for k in lod[0]}
# {"name": ["Asha", "Ravi"], "score": [88, 73]}

Outer comprehension iterates the keys (taken from the first row lod[0], since all rows share keys). Inner comprehension walks every row to gather that key's values. This literally performs the swap.

L4.2 — DoL → LoD (the reverse transpose)

Convert back:

dol = {"name": ["Asha", "Ravi"], "score": [88, 73]}
Recall Solution L4.2
keys = list(dol)                 # ["name", "score"]
n = len(dol[keys[0]])            # rows = length of any column = 2
lod = [{k: dol[k][i] for k in keys} for i in range(n)]
# [{"name":"Asha","score":88}, {"name":"Ravi","score":73}]

Why list(dol) gives ["name", "score"]: in Python, iterating a dict — or wrapping it in list(...) — walks its keys, not its values. So list(dol) is a plain list of the column names, in insertion order. (To get values you would ask for dol.values(); to get pairs, dol.items().) We want the column names here, so list(dol) is exactly right. Then loop the row index i from 0 to n-1. For each i, build one dict by reading the i-th element of every column. That's dol[k][i] — the DoL coordinate — assembled into lod[i][k].

L4.3 — Group into a dict of lists

Given a LoD of transactions, build a DoL mapping each city to the list of amounts in that city.

tx = [{"city": "Pune", "amt": 100},
      {"city": "Delhi", "amt": 40},
      {"city": "Pune", "amt": 60}]
Recall Solution L4.3
groups = {}
for t in tx:
    groups.setdefault(t["city"], []).append(t["amt"])
# {"Pune": [100, 60], "Delhi": [40]}

setdefault(key, []) returns the existing list for key, or creates a fresh empty list the first time we see that key, then we .append. Result: Pune[100, 60], Delhi[40]. Sum per city: Pune = 160, Delhi = 40.


Level 5 — Mastery

Goal: survive the corner cases — missing keys, mutation-while-iterating, degenerate inputs.

L5.1 — Ragged rows (missing keys)

This LoD has a row missing the score key. Write a transpose to DoL that does not crash, filling missing cells with None.

lod = [{"name": "Asha", "score": 88},
       {"name": "Ravi"}]            # no "score"!
Recall Solution L5.1

First collect the full key set across all rows (not just lod[0], which would miss keys some rows have and others don't). Then use .get(k) which returns None when a key is absent.

keys = {k for row in lod for k in row}          # {"name", "score"}
dol = {k: [row.get(k) for row in lod] for k in keys}
# {"name": ["Asha", "Ravi"], "score": [88, None]}

The score column is [88, None] — Ravi's missing cell became None instead of raising KeyError. This is exactly the shape JSON and APIs hands you when records are inconsistent.

L5.2 — Delete rows safely

Remove everyone with score < 80 from people_lod. Explain why the naive in-place loop is wrong, then give the correct code and the surviving names.

Recall Solution L5.2

Wrong (mutating while iterating):

for p in people_lod:
    if p["score"] < 80:
        people_lod.remove(p)   # BUG: shifts indices mid-loop → skips elements

When you remove an item, everything after it shifts left, but the loop's internal counter still advances — so the element that slid into the freed slot is skipped. Correct (build a new list):

people_lod = [p for p in people_lod if p["score"] >= 80]
# keeps Asha(88), Meera(95); drops Ravi(73)

Surviving names: ["Asha", "Meera"]. See Loops and Iteration for why filtering into a new list is the safe idiom.

L5.3 — Degenerate inputs

Predict the result (or error) for each, and give the safe version.

  1. Average score of an empty LoD [].
  2. Transpose an empty LoD [] to DoL.
Recall Solution L5.3
  1. sum(p["score"] for p in []) / len([]) → sum is 0, len is 0ZeroDivisionError. Safe: avg = total / n if n else 0 (or None). Guard the divide-by-zero explicitly.
  2. {k: [...] for k in lod[0]}lod[0] on an empty list is IndexError (no first row). Safe: dol = {} if not lod else {k: [row.get(k) for row in lod] for k in lod[0]}. An empty dataset has no columns, so an empty DoL {} is the right answer.

Score yourself

Connections

  • Lists — the outer container of LoD, inner of DoL
  • Dictionaries — key→value lookups powering both shapes
  • List Comprehensions — the engine behind every transpose here
  • JSON and APIs — where ragged real-world LoDs come from
  • Pandas DataFrame — a fast dict-of-lists with column ops built in
  • Loops and Iteration — safe traversal and the mutate-while-iterate trap
  • Mutable vs Immutable — why the shared-list bug bites