Python Part I · Module 8 · Dictionaries
Nested Results: Test to Seed to Outcome
Real result data has levels. A regression does not produce one outcome per test — it produces one outcome per test per seed, and a reviewer wants to ask about both. Chapter 8.2 said a dictionary value may be anything, and a dictionary is a value, so the structure for that already exists without any new syntax. What is genuinely new is navigating it: reading two levels down, building a level that does not exist yet, and — the part that takes real care — working out which of two lookups failed when something raises. This chapter also argues for restraint, because the structure that makes two levels easy makes five levels unreadable.
Intermediate15 min readPythonPart IFundamentalsDictionariesNesting
Part I · Module 8 · Chapter 8.7 · Nested Results: Test to Seed to Outcome
1. A Value Can Be a Dictionary
Chapter 8.2 established that values have no restriction. A dictionary is a value, so this is an ordinary structure:
runs = {"axi_burst": {48210: "PASS", 48211: "FAIL"}}
print(runs["axi_burst"][48211])FAILOne test, two seeds, one outcome each. The outer keys are test names; the inner keys are seeds.
Nothing new was introduced to write that. The outer dictionary has one entry whose value happens to be another dictionary — and Chapter 5.7 made the same point for lists of lists.
The two levels count separately:
runs = {"axi_burst": {48210: "PASS", 48211: "FAIL"}}
print(len(runs))
print(len(runs["axi_burst"]))1
2One entry at the outer level, two at the inner. len() counts at one level, exactly as it did for nested lists.
2. Two Brackets Are Two Lookups
runs["axi_burst"][48211] looks like special nested syntax. It is not — it is Chapter 5.2's rule, which has not changed: each bracket applies to whatever is on its left.
Here it is with the middle step given a name, so you can watch it happen:
runs = {"axi_burst": {48210: "PASS", 48211: "FAIL"}}
inner = runs["axi_burst"]
print(inner)
print(type(inner))
print(inner[48211]){48210: 'PASS', 48211: 'FAIL'}
<class 'dict'>
FAILruns["axi_burst"] is evaluated first and produces a dictionary — the inner one. Then [48211] is applied to that dictionary and produces its value.
So the order is always: outer key first, then inner key. runs["axi_burst"][48211] reads as "the axi_burst entry, then its 48211 entry".
And because the middle result is an ordinary dictionary, everything from this module applies to it — len(), in, .get(), assignment, iteration.
3. A Record per Test
The other common shape nests a record inside a table:
results = {
"smoke": {"status": "PASS", "runtime_s": 12.4},
"reset": {"status": "FAIL", "runtime_s": 8.7},
}
print(len(results))
print(results["reset"]["status"])
print(results["smoke"]["runtime_s"])2
FAIL
12.4Two tests, each with a small record of named fields. This is Chapter 8.2's two shapes combined: a table at the outer level and a record at the inner.
It is probably the most common structure in real result data, and it reads well because every bracket names something meaningful — no position numbers anywhere.
4. Building One Level at a Time
Reading nested data is straightforward. Building it has one trap.
The natural attempt fails:
table = {}
table["smoke"]["status"] = "PASS"KeyError: 'smoke'Read it with Section 2's rule. table["smoke"] is evaluated first, and there is no "smoke" key — so the lookup raises before the assignment is even considered. You cannot assign into a dictionary that does not exist yet.
The inner dictionary has to exist first:
table = {}
table["smoke"] = {}
table["smoke"]["status"] = "PASS"
table["smoke"]["runtime_s"] = 12.4
print(table){'smoke': {'status': 'PASS', 'runtime_s': 12.4}}Line 2 creates the inner dictionary; lines 3 and 4 fill it in. After line 2, table["smoke"] produces a real dictionary, so assigning into it works.
More often you have the whole record at once, and then it is one step:
table = {"smoke": {"status": "PASS", "runtime_s": 12.4}}
table["reset"] = {"status": "FAIL", "runtime_s": 8.7}
print(len(table))
print(table["reset"]["status"])2
FAILThat is Chapter 8.4's add-or-replace rule at the outer level, with a dictionary as the value.
Updating a field deep inside needs no special treatment:
results = {"reset": {"status": "FAIL", "runtime_s": 8.7}}
results["reset"]["status"] = "PASS"
print(results["reset"]){'status': 'PASS', 'runtime_s': 8.7}The outer lookup produced the inner dictionary, and the assignment changed one of its entries. The outer dictionary was never modified — it still has one entry, pointing at the same inner dictionary, which is now different.
5. Walking the Outer Level
Chapter 8.5's items() works here, with the value simply being a dictionary:
results = {
"smoke": {"status": "PASS", "runtime_s": 12.4},
"reset": {"status": "FAIL", "runtime_s": 8.7},
}
for name, info in results.items():
print(name, info["status"])smoke PASS
reset FAILEach pass gives a test name and its record; info["status"] then reads one field out of that record. Note that info is an ordinary dictionary inside the loop body — nothing about being nested changes it.
Going through both levels means a loop inside a loop, and nested loops are Module 10. One level at a time is enough for everything in this module.
6. Which Level Failed?
This is the skill the canonical purpose asks for, and it is worth practising deliberately because the error messages are genuinely informative.
The outer key is missing:
runs = {"axi_burst": {48210: "PASS"}}
print(runs["nope"][48210])KeyError: 'nope'The key named in the error is 'nope' — an outer key. The second bracket never ran at all, because evaluation stopped at the first.
The inner key is missing:
runs = {"axi_burst": {48210: "PASS"}}
print(runs["axi_burst"][99999])KeyError: 99999Now the key named is 99999 — an inner key. The outer lookup succeeded and handed over the inner dictionary; the failure happened one level down.
.get() from Chapter 8.3 works at either level, and it is worth being clear about what it protects:
runs = {"axi_burst": {48210: "PASS"}}
print(runs.get("nope"))
print(runs.get("axi_burst").get(99999, "no result"))None
no resultThe first guards the outer lookup. The second guards the inner one — but only because the outer lookup succeeded. runs.get("nope").get(48210) would still fail, because .get("nope") returns None and None has no .get. Guarding a two-level access properly needs the decision-making from Module 9.
7. Values Can Be Lists Too
Nesting is not limited to dictionaries inside dictionaries:
fails = {"axi_burst": [48211, 48215], "reg_rw": [77]}
print(len(fails))
print(fails["axi_burst"][0])
print(len(fails["axi_burst"]))2
48211
2Two tests, each mapped to a list of the seeds that failed. Here the second bracket is a list index — a position — while the first was a dictionary key. Same left-to-right rule, two different kinds of lookup in one expression.
This shape is right when the inner data is a sequence of things rather than named fields, which is Chapter 8.8's question applied one level down.
And Chapter 8.6's warning applies with full force here:
fails = {"axi_burst": [48211]}
copy = fails.copy()
copy["axi_burst"].append(48215)
print(fails["axi_burst"])[48211, 48215]A shallow copy separates the outer mapping and shares the inner list. For nested structures that is the normal consequence rather than an edge case.
8. How Deep Is Too Deep
Three levels work exactly as two do:
deep = {"a": {"b": {"c": "value"}}}
print(deep["a"]["b"]["c"])valueNothing is wrong with that line, and you should still think twice before writing the structure.
Each level you add multiplies what a reader has to hold in their head: what the keys at each level mean, which level a given variable is standing on, which level an error came from, and what a copy does and does not protect. Two levels are usually clear. Three are manageable when each level has an obvious meaning — test, then seed, then field. Beyond that, expressions like d["a"]["b"]["c"]["d"] stop carrying their own explanation.
9. Predict, Run, Observe
Predict every line before reading on. Two of these raise.
runs = {
"smoke": {"status": "PASS", "seeds": [1, 2]},
"reset": {"status": "FAIL", "seeds": [3]},
}
print(len(runs))
print(len(runs["smoke"]))
print(runs["reset"]["status"])
print(runs["smoke"]["seeds"][1])
print(len(runs["reset"]["seeds"]))
print(runs.get("nope"))
print(runs["smoke"]["runtime"])The verified output for the first six:
2
2
FAIL
2
1
Noneand the seventh:
KeyError: 'runtime'len(runs) is 2 — two tests. len(runs["smoke"]) is also 2, but it counts the inner record's two fields. The same number for two completely different reasons, which is exactly the level confusion this chapter is about.
runs["smoke"]["seeds"][1] is 2: outer key, inner key, then a list position.
And the final error names 'runtime' — an inner key, so the outer lookup succeeded and the failure is one level down.
10. Exercises
Exercise 1 — build a two-level table. Create a structure holding, for each of two tests, a record with a status and a runtime. Print the number of tests, the number of fields in one record, and one specific runtime.
Exercise 2 — predict. For d = {"a": {"x": 1, "y": 2}, "b": {"x": 9}}, give each result and verify:
(a) len(d) (b) len(d["a"]) (c) d["b"]["x"] (d) d["a"]["y"] (e) type(d["a"]) (f) d.get("c")
Exercise 3 — which level. For each error, say whether the outer or the inner lookup failed and how you can tell:
(a) KeyError: 'reg_rw' where outer keys are test names (b) KeyError: 4210 where inner keys are seeds (c) KeyError: 'status' where inner keys are field names
Exercise 4 — the building trap. This raises. Explain in three sentences why, referring to the order the brackets are evaluated in, and give the two-line fix:
table = {}
table["smoke"]["status"] = "PASS"Exercise 5 — two kinds of bracket. For fails = {"burst": [11, 15]}, say what kind of lookup each bracket in fails["burst"][0] performs, and predict the result. Then predict len(fails) and len(fails["burst"]) and explain why they differ.
Exercise 6 — split the access. Rewrite runs["smoke"]["seeds"][1] as three lines, each with a named intermediate result, printing the type at each step. Say in one sentence when you would do this in real code.
Exercise 7 — the shallow copy. Predict both prints, verify, and explain in two sentences which chapter's rule produced the result:
a = {"t": {"status": "PASS"}}
b = a.copy()
b["t"]["status"] = "FAIL"
print(a["t"]["status"])Exercise 8 — judge the design. A colleague proposes data[project][block][test][seed][field]. Write three sentences: what each level would have to mean for this to be defensible, what goes wrong when a reader meets an error from it, and what you would ask them before approving it.
11. Summary
- A dictionary value may be a dictionary, so nested structures need no new syntax.
len()counts one level. A two-test table haslen()2 regardless of how many fields each record holds.- Two brackets are two ordinary lookups, left to right: the outer key produces a dictionary, and the next bracket is applied to that.
- Naming the middle step is the debugging technique when a nested access misbehaves.
- You cannot assign into an inner dictionary that does not exist —
table["smoke"]["status"] = xon an empty table raisesKeyError: 'smoke'. Create the inner dictionary first. - Updating a nested field changes the inner dictionary, leaving the outer mapping untouched.
items()walks the outer level, handing you each record as an ordinary dictionary. Walking both levels needs nested loops — Module 10.- The
KeyErrornames the key, which tells you the level. A test name means outer; a seed or field name means inner. .get()guards one level at a time. Chaining it across two levels fails when the outer lookup returnsNone.- Values may be lists, in which case the second bracket is a position rather than a key.
- A shallow copy shares nested values — the normal consequence for nested data, not an edge case.
- Nest because the data has levels. If you cannot say in one sentence what each level represents, the structure is wrong rather than deep.
Two chapters remain, and both are about choosing.
Where this fits
Part of the Python curriculum.
