Introduction
Nested dictionaries in Python store hierarchical data as dictionaries within dictionaries. The best implementation depends on how deep and dynamic the nesting is. For fixed-depth nesting, collections.defaultdict with a factory function is the cleanest approach. For arbitrary-depth nesting, a recursive defaultdict (sometimes called an autovivifying dictionary) automatically creates intermediate levels. For typed data, dataclasses or TypedDict provide structure with IDE support and type checking.
The Problem with Plain Dicts
1data = {}
2
3# Accessing nested keys requires manual creation at every level
4data["users"]["alice"]["age"] = 30
5# KeyError: 'users'
6
7# Manual approach — verbose and error-prone
8if "users" not in data:
9 data["users"] = {}
10if "alice" not in data["users"]:
11 data["users"]["alice"] = {}
12data["users"]["alice"]["age"] = 30
Method 1: defaultdict for Fixed-Depth Nesting
1from collections import defaultdict
2
3# Two levels: key -> key -> value
4two_level = defaultdict(dict)
5two_level["users"]["alice"] = {"age": 30, "role": "admin"}
6two_level["users"]["bob"] = {"age": 25, "role": "user"}
7print(two_level["users"]["alice"]["age"]) # 30
8
9# Two levels: key -> key -> list
10grouped = defaultdict(lambda: defaultdict(list))
11grouped["2025"]["Q1"].append(1000)
12grouped["2025"]["Q1"].append(1200)
13grouped["2025"]["Q2"].append(1500)
14print(grouped["2025"]["Q1"]) # [1000, 1200]
15
16# Three levels
17three_level = defaultdict(lambda: defaultdict(dict))
18three_level["US"]["CA"]["population"] = 39000000
19three_level["US"]["NY"]["population"] = 19500000
Each level requires wrapping defaultdict in another defaultdict. This works well for 2-3 levels but becomes unwieldy for deeper nesting.
Method 2: Recursive defaultdict (Autovivification)
1from collections import defaultdict
2
3def nested_dict():
4 return defaultdict(nested_dict)
5
6data = nested_dict()
7
8# Arbitrary depth — no KeyError at any level
9data["a"]["b"]["c"]["d"]["e"] = "deep value"
10data["users"]["alice"]["settings"]["theme"] = "dark"
11data["users"]["alice"]["settings"]["language"] = "en"
12
13print(data["users"]["alice"]["settings"]["theme"]) # "dark"
This creates new defaultdict instances on the fly for any access. Convert to regular dicts for serialization:
1import json
2
3def to_regular_dict(d):
4 if isinstance(d, defaultdict):
5 d = {k: to_regular_dict(v) for k, v in d.items()}
6 return d
7
8regular = to_regular_dict(data)
9print(json.dumps(regular, indent=2))
Method 3: Custom NestedDict Class
1class NestedDict(dict):
2 """Dictionary that creates nested dicts on access."""
3
4 def __missing__(self, key):
5 value = self[key] = NestedDict()
6 return value
7
8data = NestedDict()
9data["users"]["alice"]["age"] = 30
10data["config"]["db"]["host"] = "localhost"
11data["config"]["db"]["port"] = 5432
12
13print(data["config"]["db"]["host"]) # "localhost"
14
15# Works with json.dumps directly (it's a regular dict subclass)
16import json
17print(json.dumps(data, indent=2))
__missing__ is called when a key is not found. This approach is cleaner than defaultdict because it subclasses dict directly and serializes naturally.
Method 4: Using setdefault for Safe Access
1data = {}
2
3# setdefault creates intermediate dicts as needed
4data.setdefault("users", {}).setdefault("alice", {})["age"] = 30
5data.setdefault("users", {}).setdefault("bob", {})["age"] = 25
6
7print(data)
8# {'users': {'alice': {'age': 30}, 'bob': {'age': 25}}}
This is verbose but works without imports and uses plain dicts throughout.
Method 5: Dataclasses for Typed Nested Data
1from dataclasses import dataclass, field
2from typing import Optional
3
4@dataclass
5class DatabaseConfig:
6 host: str = "localhost"
7 port: int = 5432
8 name: str = "mydb"
9
10@dataclass
11class AppConfig:
12 debug: bool = False
13 database: DatabaseConfig = field(default_factory=DatabaseConfig)
14 allowed_origins: list[str] = field(default_factory=list)
15
16config = AppConfig(
17 debug=True,
18 database=DatabaseConfig(host="db.example.com", port=5433),
19 allowed_origins=["https://example.com"]
20)
21
22print(config.database.host) # "db.example.com"
23print(config.database.port) # 5433
Dataclasses provide IDE autocompletion, type checking, and clear structure. Use them when the nested structure is known at development time.
Safe Deep Access with get()
1data = {"users": {"alice": {"age": 30}}}
2
3# Chained .get() with defaults
4age = data.get("users", {}).get("alice", {}).get("age")
5print(age) # 30
6
7# Missing key returns None instead of KeyError
8name = data.get("users", {}).get("bob", {}).get("name")
9print(name) # None
10
11# Helper function for deep access
12def deep_get(d, *keys, default=None):
13 for key in keys:
14 if isinstance(d, dict):
15 d = d.get(key, default)
16 else:
17 return default
18 return d
19
20print(deep_get(data, "users", "alice", "age")) # 30
21print(deep_get(data, "users", "bob", "age", default=0)) # 0
Safe Deep Set
1def deep_set(d, keys, value):
2 for key in keys[:-1]:
3 d = d.setdefault(key, {})
4 d[keys[-1]] = value
5
6data = {}
7deep_set(data, ["users", "alice", "age"], 30)
8deep_set(data, ["users", "alice", "role"], "admin")
9deep_set(data, ["config", "db", "host"], "localhost")
10
11print(data)
12# {'users': {'alice': {'age': 30, 'role': 'admin'}},
13# 'config': {'db': {'host': 'localhost'}}}
Merging Nested Dictionaries
1def deep_merge(base, override):
2 result = base.copy()
3 for key, value in override.items():
4 if key in result and isinstance(result[key], dict) and isinstance(value, dict):
5 result[key] = deep_merge(result[key], value)
6 else:
7 result[key] = value
8 return result
9
10defaults = {"db": {"host": "localhost", "port": 5432}, "debug": False}
11overrides = {"db": {"host": "prod.example.com"}, "debug": True}
12
13config = deep_merge(defaults, overrides)
14print(config)
15# {'db': {'host': 'prod.example.com', 'port': 5432}, 'debug': True}
Common Pitfalls
defaultdict creates keys on read access: Accessing d["nonexistent"] silently creates that key with an empty default. This causes unexpected keys to appear. Use key in d to check existence without side effects.
Sharing mutable defaults: dict.fromkeys(["a", "b"], {}) makes all keys share the same inner dict. Use {k: {} for k in keys} to create independent inner dicts.
JSON serialization of defaultdict: json.dumps(defaultdict_obj) works because defaultdict subclasses dict, but the default factory is lost on deserialization. Convert to regular dicts first if round-tripping matters.
No type safety with nested dicts: Typos in key names cause silent bugs (data["usres"] creates a new key instead of raising an error). Use dataclasses or TypedDict for structured data where keys are known.
Recursive nesting with no depth limit: An autovivifying dict never raises KeyError, making bugs harder to catch. Consider adding a maximum depth or using a regular dict with explicit key checks for production data.
Summary
Use defaultdict(dict) or defaultdict(lambda: defaultdict(list)) for 2-3 levels of nesting
Use a recursive defaultdict or custom NestedDict class for arbitrary-depth nesting
Use dataclasses or TypedDict when the nested structure is known at development time
Use deep_get and deep_set helpers for safe access to deeply nested plain dicts
Convert defaultdict to regular dict before JSON serialization to avoid losing the factory function