A nested dictionary is a dictionary where one or more values are themselves dictionaries. The shape mirrors how real data looks: an order has a customer, who has an address, which has a country. A configuration has sections, each with its own keys. A grouping has categories, each holding a sub-map of items. This lesson covers how to read, write, build, iterate, and update nested dicts, and when a different data model (a dataclass or a typed dict) is the better fit.
A dictionary's values can be anything: numbers, strings, lists, other dictionaries. When a value is itself a dictionary, the result is a two-level (or three-level, or deeper) structure. Each inner dictionary stands on its own and follows the same rules as any other dictionary.
The outer dictionary has three keys: "name", "email", and "address". The value at "address" is another dictionary with its own three keys. Reading the city is two bracket lookups: first customer["address"] to get the inner dict, then ["city"] on that result to get the value.
The diagram shows the two-level structure. The outer dict points to the address dict, which has its own keys and values. From the outside, the address looks like a single value at one key. Inside, it's a whole dictionary with its own rules.
Nesting can go arbitrarily deep. The same access pattern keeps stacking brackets:
Three brackets, three levels deep. The shape comes up constantly with JSON data, REST API responses, configuration files, and anywhere the world has a "thing inside a thing" structure. JSON objects map directly onto this representation in Python.
Several situations call for nested dictionaries.
JSON-shaped data. Responses from web APIs and records stored in JSON files come back as nested dicts. The structure is whatever the producer chose, and the consuming code reads it with the same bracket pattern.
Configuration files. App configs often group settings by area: a "database" section, a "cache" section, a "logging" section. Each section has its own keys.
Grouped lookups. For lookups by two keys, a nested dict keyed first by group, then by item, is one shape that works. Prices grouped by category, stock counts grouped by warehouse, ratings grouped by product.
The same data could live in a flat dict keyed on (category, name) tuples, or in a list of records, or in a proper class. The nested form wins when the outer keys are a natural grouping that will be iterated over as units (for example, "print all prices for the Audio category").
Multi-level groupings. Aggregated reports often come as year -> month -> total or country -> city -> orders. Each level is a real group, not an artificial split.
Reading from a nested dictionary chains bracket lookups. Each pair of brackets descends one level. The whole expression evaluates left-to-right.
Python evaluates customer["address"] first, which returns the inner dict {"city": "NYC", "country": "US"}. Then it evaluates ["city"] on that result, which returns "NYC". The chained form is a shorthand for assigning the intermediate result to a variable.
Same result. The chained version is shorter; the two-step version shows the underlying steps.
If any key along the chain is missing, the lookup raises KeyError at that point, and the rest of the chain never runs.
The error is KeyError: 'address' because the outer "address" key is missing. Python doesn't try to look up "city". The same thing happens at any level: if the outer key is there but the inner key isn't, the error is KeyError: 'city' instead.
.get()When a missing key is a real possibility (data from an external source, optional config sections, user-supplied input), KeyError is too loud. The .get() method returns a default instead of raising. For nested access, the technique is to default to an empty dict at the intermediate levels so the next .get() call still works.
The first .get("address", {}) returns {} because the key is missing. The second .get("city", "unknown") runs on that empty dict, finds no "city", and returns "unknown". No exception, no crash, just a sensible fallback.
The same pattern works for three or more levels:
Each .get(..., {}) keeps the chain alive even when a level is missing. The final .get(..., "N/A") provides the real default value.
Each .get(...) is one O(1) dictionary lookup. The chain .get(...).get(...) adds a few of these, plus the cost of creating the empty {} default. For deep optional access this is fine; for paths known to exist, direct bracket lookup is cleaner and slightly cheaper.
A common mistake: defaulting to None instead of {} in the middle of the chain.
What's wrong with this code?
None is not a dictionary, so calling .get() on it raises AttributeError. The fix is to default to an empty dict {} at every intermediate level, not None. The last call in the chain can default to any value, since that value gets used directly.
Fix:
For very deep nesting, the chain of .get(..., {}) calls becomes noisy. A small helper handles arbitrary depth cleanly:
deep_get walks the keys one at a time. If any level isn't a dict or doesn't have the key, it returns the default. This pattern shows up often enough to keep in a utilities module.
Assigning to a nested key uses the same chained-bracket syntax. The intermediate dictionaries must exist already, or the assignment fails.
customer["address"] evaluates to the inner dict, and ["city"] = "Boston" writes into it. The change happens in place; the outer dict still points to the same inner dict, but the inner dict's "city" is now updated.
If the intermediate key doesn't exist, the same syntax raises KeyError:
To safely write to a path that may not exist yet, create the intermediate dict first, or use setdefault.
setdefault: Create Intermediate Dicts on DemandThe setdefault(key, default) method does two things at once: if the key exists, it returns the current value; if the key doesn't exist, it inserts the default and returns it. For nested writes, this creates the inner dict on first use, then writes into it on subsequent calls.
The first call warehouses.setdefault("US", {}) creates an empty inner dict at "US" and returns it. The ["NYC"] = "Empire Warehouse" writes into that inner dict. The second setdefault("US", {}) finds "US" already there and returns the existing inner dict (it does not replace it with a fresh empty one), so the next assignment adds to the same inner dict.
Without setdefault, the equivalent code needs an if-check at every level:
That works, but it gets verbose fast with multiple levels. setdefault collapses the check-then-create pattern into one call.
defaultdict: Auto-Create Missing KeysFor repeated nested writes, the collections.defaultdict class is even cleaner. A defaultdict calls a factory function the moment a missing key is accessed, producing a default value automatically.
defaultdict(dict) means "any missing key gets a fresh empty dict as its value". The first write to warehouses["US"]["NYC"] causes Python to look up "US" in warehouses, find nothing, call the factory dict() to make {}, store that as the value at "US", and then write "NYC" into the new inner dict. After that point, "US" is a real key.
For two levels of nesting, a defaultdict of defaultdict(dict) handles the inner level too:
The lambda: defaultdict(dict) is the factory for the outer defaultdict: every missing outer key gets a fresh defaultdict(dict) as its value, which in turn auto-creates inner dicts on demand. Three levels of auto-creation, no setdefault or if-checks anywhere.
defaultdict access is the same O(1) as a regular dict on existing keys. On missing keys, it pays an extra factory call (one function call) before the assignment. For building nested structures from streams of data, this is faster than the equivalent setdefault or if-then-create chains.
Iterating a nested dict means iterating the outer dict, then iterating each inner dict. The pattern is a pair of nested loops.
The outer .items() yields (category, products) pairs, where products is the inner dict. The inner .items() yields the (name, price) pairs from that inner dict. Two levels of unpacking, no indexing.
If the values are mixed (some inner dicts, some not), check the type before iterating:
isinstance(value, dict) decides whether to descend or print the value directly. This pattern applies when the structure isn't uniform across all keys.
For arbitrarily deep nested dicts (think config files or JSON responses), a recursive function flattens the whole thing into a flat dict with dotted keys. Each recursive call descends one level deeper.
The function walks each key-value pair. If the value is itself a dict, it recurses and merges the result with the accumulated prefix. If the value isn't a dict, it stores it directly under the dotted key. The result is one flat dict where every key encodes the original path.
Flattening visits every leaf value exactly once, so it's O(N) in the number of leaves. The recursion depth equals the deepest level of nesting, which is rarely a problem in practice (Python's default recursion limit is 1,000).
A bug shows up when multiple inner dicts that appear independent share the same object. The classic mistake is using a mutable default in a function or in dict.fromkeys.
What's wrong with this code?
dict.fromkeys(keys, value) uses the same value object for every key, so all three countries point to the same inner dict. A write into one of them is visible through all of them.
Fix:
A dict comprehension creates a fresh {} for each key, so the inner dicts are independent. The same trap exists with mutable default arguments to functions (def f(d={}): ... shares one dict across every call). The rule: never use the same mutable object as the value for multiple keys unless the sharing is intentional.
Python's dict.update() (and the |= operator added in 3.9) replaces keys at the top level. It doesn't merge inner dicts; it overwrites them entirely.
The "host" key inside "database" is gone. update replaced the entire inner dict with the new one. To keep the host and only change the port, merge manually:
Calling update on the inner dict directly merges at the inner level. This is the correct form for "change one field in this section without touching the rest".
For deep merges across arbitrary levels, a small helper does the job:
The helper recurses when both the existing value and the override are dicts; otherwise it overwrites. This pattern fits layered config files where a base file defines defaults and overrides only change specific fields.
Nested dicts are convenient for free-form data with unknown shape, like JSON from an external source. They become a liability when the shape is fixed and known up front, because:
customer["adress"] (missing d) raises KeyError only at runtime, often far from where the bug was introduced.Three alternatives apply to known shapes.
A dataclass describes a record with named fields and types. It's a regular Python class with auto-generated boilerplate.
Attribute access (.address.city) replaces bracket access. Editors can autocomplete the fields. Type checkers warn on a misspelled adress. The structure is documented in the class definition. Dataclasses appear properly in the classes and objects section.
TypedDict describes the expected shape of a dict so a type checker (mypy, pyright) catches typos and wrong types, while the actual value is still a regular dict.
The dictionary works exactly like a normal nested dict at runtime. The benefit is at edit-and-check time: the type checker knows the expected fields and flags mistakes. TypedDict bridges "everything is a dict" code (JSON-shaped data) and "everything is a class" code (real records).
For data coming in from outside the program (HTTP requests, config files, message queues), libraries like pydantic validate and convert nested dicts into typed objects. The cost is an extra dependency and a bit more code; the gain is automatic validation, conversion, and clear errors when the shape is wrong. This is beyond the scope of this lesson; the library exists for cases where plain nested dicts aren't enough.
A rule of thumb:
| Situation | Best fit |
|---|---|
| JSON response with unknown or varying shape | Nested dict |
| Small lookup or grouping where keys vary at runtime | Nested dict |
| Known, fixed record passed around in code | dataclass |
| JSON-shaped data with a known schema that needs type checks | TypedDict |
| External input that must be validated | pydantic (or similar) |
The nested dict is the right starting point. Use the alternatives once the structure stabilizes and the code starts documenting it in comments or chasing typo-bugs.
10 quizzes