PYTHON / DICTIONARIES AND SETS
Nested dictionaries and JSON-shaped data
Navigate, mutate, and safely probe dictionaries that contain dictionaries and lists, and understand what survives a JSON round trip.
What you will learn
- Chain subscripts to reach values inside nested dicts and lists of dicts
- Use .get(key, {}) chains to probe a path that may not exist
- Build nested levels on demand with setdefault instead of pre-declaring them
- Know that json turns dict keys into strings and tuples into lists
Understanding Nested dictionaries and JSON-shaped data
A nested dictionary is not a special type. It is an ordinary dict whose values happen to be other dicts or lists, so order["customer"]["address"]["city"] is three independent lookups performed left to right. Each one must succeed before the next runs, which is why a typo in the middle of the chain raises KeyError naming only that middle key, not the whole path you were aiming for.
The shape you get back from an API or a config file is usually restricted to what JSON can express: objects (dicts with string keys), arrays (lists), strings, numbers, booleans, and null. That restriction is why real data is an alternating stack of dicts and lists, and why you constantly switch between string subscripts and integer indexes as you descend. json.loads gives you exactly that alternating structure, and json.dumps will convert non-string dict keys to strings and tuples to lists rather than preserving them, so a round trip is lossy for anything richer.
Because the inner objects are referenced, not copied, a nested dict behaves like a tree of shared nodes. dict(d) and d.copy() duplicate only the top level, so both the original and the copy point at the same inner dicts, and writing through one is visible through the other. copy.deepcopy is the tool when you need an independent subtree, and a template dict you reuse across records is the classic place this bites.
order = {
"id": 4711,
"customer": {"name": "Ada", "address": {"city": "Vienna", "zip": "1010"}},
"items": [
{"sku": "A-1", "qty": 2, "price": 9.5},
{"sku": "B-7", "qty": 1, "price": 24.0},
],
}
print(order["customer"]["address"]["city"])
print(order["items"][1]["sku"])
country = order.get("customer", {}).get("address", {}).get("country", "unknown")
print(country)
total = sum(item["qty"] * item["price"] for item in order["items"])
print(total)
order["customer"]["address"]["country"] = "AT"
print(order["customer"]["address"])Nested data is just references chained together, so every level of access, defaulting, and copying happens one level at a time.
Worked examples
What a JSON round trip changes
Shows that non-string dictionary keys come back as strings after dumps/loads.
import json
data = {2024: {"revenue": 1200}, 2025: {"revenue": 1500}}
text = json.dumps(data)
print(text)
back = json.loads(text)
print(list(back.keys()))
print(back["2024"]["revenue"])Example explained
Line 1json.dumps accepts the int keys 2024 and 2025 but writes them as quoted strings, because JSON objects only have string keys.
Line 2After json.loads the keys are the strings '2024' and '2025', so back[2024] would raise KeyError.
Line 3The inner values are untouched: 1200 stays an int, so back["2024"]["revenue"] is usable arithmetic.
Growing levels with setdefault
Builds a two-level dict from flat rows without knowing the keys in advance.
rows = [
("eu", "de", 3),
("eu", "at", 1),
("us", "ca", 7),
("eu", "de", 2),
]
tree = {}
for region, country, n in rows:
tree.setdefault(region, {}).setdefault(country, 0)
tree[region][country] += n
print(tree)
print(tree["eu"]["de"])Example explained
Line 1tree.setdefault(region, {}) returns the existing inner dict if present and otherwise inserts and returns a fresh one.
Line 2The second setdefault seeds the counter at 0 so the += on the next line always has a number to add to.
Line 3The 'de' row appears twice and accumulates to 5, proving the inner dict was reused rather than replaced.
A shallow copy shares the inner dicts
Demonstrates why dict(d) is not enough to protect nested values.
import copy
template = {"prefs": {"theme": "dark"}}
a = dict(template)
a["prefs"]["theme"] = "light"
print(template["prefs"]["theme"])
b = copy.deepcopy(template)
b["prefs"]["theme"] = "solarized"
print(template["prefs"]["theme"])Example explained
Line 1dict(template) creates a new outer dict whose 'prefs' value is the very same inner dict object.
Line 2Writing a["prefs"]["theme"] mutates that shared inner dict, so the template changes too.
Line 3copy.deepcopy rebuilds every level, so the write through b leaves the template at 'light'.
Walking a mixed dict/list structure
Iterates two levels deep where the middle level is a list of dicts.
report = {
"teams": [
{"name": "api", "members": [{"user": "ada"}, {"user": "lin"}]},
{"name": "web", "members": [{"user": "raj"}]},
]
}
for team in report["teams"]:
names = [m["user"] for m in team["members"]]
print(team["name"], len(names), ",".join(names))Example explained
Line 1report["teams"] is a list, so the for loop yields dicts and each team is subscripted by string.
Line 2team["members"] is again a list of dicts, which is why the comprehension needs m["user"] rather than m.
Line 3Nothing here needs recursion because the depth is known and fixed by the data format.
Important notes
json.dumps refuses sets and non-scalar keys with TypeError, so convert them (list(myset), "-".join(key)) before serializing.
get(key, {}) never inserts anything; only setdefault mutates the dictionary, which matters when you later check whether a level existed.
Common mistakes
Writing d.get("customer")["city"] and expecting None on a miss: get returns None and subscripting it raises TypeError: 'NoneType' object is not subscriptable.
Assuming d["a"]["b"]["c"] reports the full missing path; the KeyError names only the first level that failed, which hides where the data really diverged.
Round-tripping a dict with int or tuple keys through json and then indexing with the original key, which raises KeyError because keys came back as strings (and tuple keys raise TypeError on dumps).
Try it yourself
Change, predict, then run
Given inventory = {"warehouse": {"a": {"bolts": 40}, "b": {"nuts": 12}}}, print the total item count across all sections, then add 5 washers to section "b" and 3 nails to a new section "c" using setdefault so the code works whether or not the section already exists.
Open the Python workspaceCheck your understanding
cfg = {"db": {"host": "localhost"}}. Why does cfg.get("cache", {}).get("ttl", 60) return 60 while cfg.get("cache").get("ttl", 60) raises an error?
- The {} default hands the second .get a dict to run on; without it the first call returns None, which has no .get method
- The second version fails because .get accepts only one argument when used on nested data
- Both are invalid; reaching into a possibly missing nested level requires try/except KeyError
- The first version works because get inserts cfg["cache"] = {} so the following lookup finds it
Show answer
Each .get is an independent call on whatever the previous expression evaluated to, so the missing 'cache' key must still produce a dict for the chain to continue; the {} default does that, while the bare get yields None and None.get does not exist. The last option is tempting but wrong: get never modifies the dictionary, that is setdefault's job, and cfg still has only the 'db' key afterwards.