Both hold the same three students. students_lod is a list on the outside; students_dol is a dict on the outside. That single difference — what the outer container is — decides every access rule below.
The table below is a checklist you tick off, not just a summary. Read it left to right, one row at a time:
The # column is a label (A–J) — a short name for a kind of problem, so we can point at it later ("this is Case F").
Case class says in plain words what situation the row describes.
What makes it tricky is the trap hiding in that situation — the reason it deserves its own example.
Covered by tells you exactly which worked example below drives that trap into the ground.
How to use it: pick any row, cover the last column with your hand, and try to predict both the trap and how you'd handle it. Then read the matching example to check yourself. When every row feels obvious, you have covered every scenario this topic can throw.
#
Case class
What makes it tricky
Covered by
A
Clean LoD read (row → field)
must index integer first, key second
Example 1
B
Clean DoL read (column → row)
must index key first, integer second
Example 1
C
Column math (average / max)
DoL shines, LoD must gather
Example 2
D
Structure conversion LoD ↔ DoL
the "transpose" — indices cross over
Example 3
E
Missing key (ragged records)
KeyError risk — need a default
Example 4
F
Empty container (zero rows)
division by zero / IndexError edge
Example 5
G
Wrong index order (the classic bug)
TypeError vs KeyError — which and why
Example 6
H
Shared-inner-list trap when building DoL
one list object, all keys mutate together
Example 7
I
Real-world word problem (filter + aggregate)
JSON-from-API style, pick the right shape
Example 8
J
Exam-style twist (mutate while iterating)
undefined behaviour, safe rebuild
Example 9
Every numeric answer produced below is machine-checked in the verify block.
students_lod[1] → the dict {"name": "Ravi", "age": 22, "marks": 73}.
Why this step? The outer container is a list, so the only thing it understands is an integer position. Index 1 means "the second row" (counting from 0).
... ["age"] → 22.
Why this step? The value from step 1 is a dict, so the next bracket is a key lookup, not a position.
Now the DoL: students_dol["age"] → [20, 22, 21].
Why this step? The outer container is a dict, so the first bracket must be a key — it hands back the whole age column.
... [1] → 22.
Why this step? That column is a list, so we finish with an integer position.
Look at the figure below: the same yellow cell (row 1, age = 22) is reached by two different arrows — and the two index tokens appear in opposite order. That mirror is the whole idea.
Figure 1 — The 3×3 student table. The red arrow (LoD) enters from the top: first the integer [1] picks row 1, then ["age"] picks the column. The green arrow (DoL) enters from the bottom: first ["age"] picks the column, then [1] picks the row. Both arrows land on the same highlighted cell 22, and the yellow caption underneath reminds you the two index tokens swap order.
Recall
students_lod[1]["age"] returns ::: 22 (row 1's dict, then its "age" field)
students_dol["age"][1] returns ::: 22 (the "age" column, then position 1) — same cell, indices swapped
DoL way:sum(students_dol["marks"]) → 88 + 73 + 95 = 256.
Why this step? The column is already a flat list, so sum runs straight over it — no digging.
Divide: 256 / 3 = 85.333....
Why this step? Three students, so len(students_dol["marks"]) == 3.
LoD way: we must gather first — [s["marks"] for s in students_lod] → [88, 73, 95].
Why this step? In LoD the marks are scattered across separate row dicts. The comprehension visits each row and pulls its "marks" out into one list. Only then can we sum.
sum([88, 73, 95]) / 3 → same 85.333....
Verify:388+73+95=3256≈85.33. The DoL path did less work (no gathering step) — that is exactly why column aggregates prefer DoL.
dol = {k: [row[k] for row in students_lod] for k in students_lod[0]}
Why this step? The outer comprehension walks the keys (name, age, marks), taken from the first row because all rows share keys. The inner comprehension walks every row collecting that key's value in row order. Result: {"name": [...], "age": [...], "marks": [...]}.
This yields dol["age"] == [20, 22, 21] — the age column, in order.
Why this step? Sanity: the column must match the original ages top-to-bottom.
DoL → LoD (rotate back):
keys = list(dol) # ["name","age","marks"]n = len(dol["name"]) # 3 rowslod2 = [{k: dol[k][i] for k in keys} for i in range(n)]
Why this step? We loop the row indexi from 0..n-1; for each i we build one dict by reading the i-th element of every column. This is data[c][i] → data[i][c] — the swap in reverse.
Compare: lod2 == students_lod?
Verify: the round trip returns the original list of dicts exactly — lod2 == students_lod is True. Rotating twice lands home, confirming LoD and DoL carry identical information, just rotated 90° — the "transpose" idea introduced in the parent note.
DoL:sum(empty_dol["marks"]) → 0 (sum of nothing is 0, by definition).
Why this step?sum over an empty list is a defined, safe 0.
len(empty_dol["marks"]) → 0.
Why this step? Zero rows means zero-length columns.
0 / 0 → ZeroDivisionError.
Why this step? The average is undefined for zero items — the language refuses to guess.
Guard it:
n = len(empty_dol["marks"])avg = sum(empty_dol["marks"]) / n if n else 0.0
Why this step? We test if n (nonzero) before dividing and return a sensible default.
LoD trap variant:empty_lod[0] → IndexError.
Why this step? There is no row 0 to read; always check if empty_lod: before indexing the first row.
Verify: with the guard, avg == 0.0 for the empty class — no crash. Empty-input handling is the single most-forgotten case in data code; always ask "what if there are zero rows?"
students_lod["name"] — outer container is a list.
Why this step? A list indexes by integer position only. It has no idea what the string "name" means as a position.
Result: TypeError: list indices must be integers or slices, not str.
Why this step? Wrong type of index → TypeError (not KeyError). This is a precise tell: seeing TypeError here means "you fed a string to a list."
Now the mirror mistake: students_dol[0] on the dict.
Why this step? A dict indexes by key. 0 is not a key in students_dol.
Result: KeyError: 0.
Why this step? Right mechanism (key lookup), missing key → KeyError.
Fix: obey the outer container. LoD → integer first (students_lod[0]["name"]). DoL → key first (students_dol["name"][0]).
Verify: both fixed forms return "Asha". The type of exception is your diagnostic: TypeError = string into a list; KeyError = wrong key into a dict.
dict.fromkeys(cols, []) creates the default value list once and points every key at that same object.
Why this step?fromkeys does not copy the default — all keys alias one list.
bad["name"].append("Asha") mutates that shared list.
Why this step? Because bad["name"] and bad["marks"] are the same list, the append shows up under both keys.
Result: bad["marks"] == ["Asha"] too — a phantom value appeared in a column you never touched.
Why this step? Aliasing (see Mutable vs Immutable): one object, two names.
Fix:
good = {c: [] for c in cols} # a FRESH list per keygood["name"].append("Asha")
Why this step? The dict comprehension runs [] once per key, creating independent lists.
Verify: with good, appending to "name" leaves good["marks"] == []; with bad it wrongly becomes ["Asha"]. Fresh-per-key is the only safe way to build a DoL from scratch.
Pick the shape: the data arrived as list of dicts (typical JSON array of objects). We filter rows, so LoD is the natural fit — each row is a self-contained record.
Why this step? Filtering "which records qualify" is a row operation; LoD keeps each row whole.
Filter:passed = [s for s in students_lod if s["marks"] >= 80].
Why this step? The comprehension keeps only rows meeting the condition → Asha and Meera.
Gather ages:ages = [s["age"] for s in passed] → [20, 21].
Why this step? Now that rows are chosen, pull just the field we need to aggregate.
Average:sum(ages) / len(ages) → 41 / 2 = 20.5.
Why this step? Two passing students; guard len(ages) if the filter could be empty (Case F!).
Verify: average passing age =220+21=20.5 years (units: years, since we averaged ages). Filter-then-aggregate on LoD is the everyday API workflow — and a natural feeder into a Pandas DataFrame if the data grows.
Trace the naive loop. It walks by an internal position counter starting at 0. At counter 0 it sees Asha (20 < 21) and removes her; now every later element shifts left by one, so Ravi is at position 0, Meera at 1, Kiran at 2.
Why this step? Removing during iteration desynchronises the loop's counter from the (now shorter) list.
The loop advances the counter to 1 — but after the shift, position 1 holds Meera (21), not Kiran. Ravi at position 0 was jumped over entirely.
Why this step? Because indices moved under the loop's feet, the element that slid into the just-vacated slot is never examined.
The loop reaches the end without ever testing Kiran (19), so Kiran survives even though he should be deleted — a silent wrong answer, not a crash.
Why this step? This is the classic "mutate-while-iterating" bug of Loops and Iteration; the traversal and the mutation fight each other, giving effectively undefined results.
Fix — rebuild, don't mutate:
people = [p for p in people if p["age"] >= 21]
Why this step? We build a new list from a fresh, complete pass, so the original is never disturbed mid-iteration. Every element is tested exactly once.
Verify: the comprehension yields exactly ["Ravi", "Meera"] (both age >= 21), i.e. 2 survivors. The rebuild is correct where the in-place removal silently kept Kiran (19) — the whole point of Case J.