Beginner · Lesson 24 of 94

Dictionaries in depth

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

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.

Dictionaries in depth: user → 'name': 'ada' → 'address' → 'city': 'London' → 'postcode': 'N1' → address → city
Dictionaries in depth · Figure 1 · Open for full size

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.

Dictionaries in depth: list: check item 1, item 2, ... until found → time grows with size → dict: compute the key's slot, look there → time stays flat
Dictionaries in depth · Figure 2 · Open for full size

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"])
Output
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}")
Output
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)
Output
{'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)
Output
True
dict lookup at least 100x faster: True

Try 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

What's next

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.

Course overview