What you'll learn
- Nest dictionaries and read a path through them safely
- Use
setdefaultto build groups without checking for the key first - Merge dictionaries with
|and update them in place withupdate - Decide when a dictionary beats a list, and understand why lookups are fast
Concept
Real data is nested. A user has an address, an address has a city, so user["address"]["city"] walks two levels down. Each level is an ordinary dictionary lookup, and a missing key at any level raises a KeyError right there. When the shape is not guaranteed, chain get calls with empty-dictionary defaults, user.get("address", {}).get("city"), so a missing address yields None instead of a crash.

Grouping items by some property is the most common thing you will do with a dictionary, and setdefault is built for it. groups.setdefault(key, []) returns the list already stored under the key, or stores a new empty list and returns that. Either way you get a list to append to, in one line, with no if key in groups dance. Counting works the same way with get(key, 0) + 1, as you saw in the word counter.
Two dictionaries merge with the pipe: defaults | overrides returns a new dictionary where keys from the right side win. settings.update(overrides) does the same in place. The right-side-wins rule is exactly what configuration needs: start with defaults, lay the user's choices on top.
| Operation | Result | Changes original? |
|---|---|---|
a | b |
new dict, b's values win on shared keys |
no |
a.update(b) |
a gains b's entries, b wins |
yes |
a.setdefault(k, []) |
value for k, creating [] if missing |
only if missing |
a.pop(k, None) |
remove and return, None if missing |
yes |
{**a, **b} |
same as a | b, older spelling |
no |
Why prefer a dictionary over a list of pairs? Speed and intent. Finding an item in a list means checking items one by one, so a lookup in a million-item list takes a million steps. A dictionary computes where the key must be and goes straight there, in about the same time for ten items or ten million. Any time you find yourself scanning a list to find "the one with this name", you want a dictionary keyed by name.

Code
Nested access, the safe way and the crashing way. The get chain returns None for the user without an address; square brackets raise.
# nested.py
users = [
{"name": "ada", "address": {"city": "London", "postcode": "N1"}},
{"name": "linus"},
]
for user in users:
print(user["name"], user.get("address", {}).get("city"))
print(users[1]["address"]["city"])
ada London
linus None
Traceback (most recent call last):
File "nested.py", line 9, in <module>
print(users[1]["address"]["city"])
~~~~~~~~^^^^^^^^^^^
KeyError: 'address'Grouping with setdefault. Each city's list is created on first sight and appended to after that, in one line.
# group.py
people = [("ada", "London"), ("grace", "New York"), ("linus", "London"), ("guido", "Amsterdam")]
by_city = {}
for name, city in people:
by_city.setdefault(city, []).append(name)
for city, names in by_city.items():
print(f"{city}: {names}")
London: ['ada', 'linus']
New York: ['grace']
Amsterdam: ['guido']Merging defaults with overrides. | builds a new dictionary; update changes one in place; on shared keys the right side wins both times.
# merge.py
defaults = {"theme": "dark", "font_size": 14, "autosave": True}
user = {"font_size": 16, "language": "en"}
settings = defaults | user
print(settings)
print(defaults)
defaults.update(user)
print(defaults)
{'theme': 'dark', 'font_size': 16, 'autosave': True, 'language': 'en'}
{'theme': 'dark', 'font_size': 14, 'autosave': True}
{'theme': 'dark', 'font_size': 16, 'autosave': True, 'language': 'en'}List scan versus dictionary lookup, timed. Both find the same item; the dictionary does not care that there are a hundred thousand entries.
# lookup_speed.py
import time
n = 100_000
pairs = [(f"user{i}", i) for i in range(n)]
lookup = dict(pairs)
target = f"user{n - 1}"
rounds = 50_000
start = time.perf_counter()
for name, value in pairs:
if name == target:
found_list = value
list_ms = (time.perf_counter() - start) * 1000
start = time.perf_counter()
for _ in range(rounds):
found_dict = lookup[target]
dict_ms = (time.perf_counter() - start) * 1000 / rounds
print(found_list == found_dict)
print("dict lookup at least 100x faster:", list_ms / dict_ms > 100)
True
dict lookup at least 100x faster: TrueTry it yourself
Create inventory.py with a list of (product, category, quantity) tuples. Build a nested dictionary stock[category][product] = quantity using setdefault. Print the quantity of one product by walking the path, use a get chain for a category that does not exist, then merge in a dictionary of restocked quantities with | and print the result.
Common mistakes
- Checking
if key in dbefore every insert.setdefaultandget(key, default)do that in one expression and read better. - Assuming
a | bchangesa. It returns a new dictionary; useupdatewhen you want the change in place. - Scanning a list for a name. If you write
for item in items: if item["name"] == target, build a dictionary keyed by name once and look it up.
What's next
- Walk nested dictionaries one key at a time; chain
getwith{}defaults when a level might be missing. setdefault(key, [])builds groups in one line;|merges into a new dict andupdatemerges in place, right side winning.- Dictionary lookups stay fast as data grows; a list scan does not.
You can shape dictionaries however data arrives. Next you will work with the nested combinations real programs actually hold: lists of dictionaries and dictionaries of lists.