Codetail

Article 7 of 15

Dictionaries

Key-value pairs. The most useful structure in Python.

25 min read

Creating dictionaries

A dictionary maps keys to values. Every key is unique. Looking up a value by key takes the same time whether the dictionary has 10 items or 10 million, because dictionaries are backed by a hash table.

Python
1# Literal syntax, most common
2user = {
3 "name": "Alice",
4 "age": 30,
5 "admin": True,
6}
7
8# dict() constructor with keyword arguments
9config = dict(host="localhost", port=5432, debug=False)
10
11# Empty dict
12cache = {}
13cache = dict()
14
15print(user["name"]) # Alice
16print(config["port"]) # 5432

dict.fromkeys()

When you need a dict with a fixed set of keys and a default value for each, dict.fromkeys() is cleaner than a loop.

Python
1# Initialize all scores to zero
2players = ["Alice", "Bob", "Carol"]
3scores = dict.fromkeys(players, 0)
4print(scores)
5# {'Alice': 0, 'Bob': 0, 'Carol': 0}
6
7# Initialize a config with None
8fields = ["host", "port", "password"]
9template = dict.fromkeys(fields)
10print(template)
11# {'host': None, 'port': None, 'password': None}

Do not use a mutable default like dict.fromkeys(keys, []). All keys would share the same list object. Use a dict comprehension instead: { k: [] for k in keys }.

Accessing values

Square bracket access is the direct way to read a value. It is fast and readable. The cost is that it raises a KeyError if the key does not exist. When you are not sure if a key is present, .get() is safer.

Python
1config = {"host": "localhost", "port": 5432}
2
3# Direct access: fast, but raises KeyError if missing
4print(config["host"]) # localhost
5
6# .get(): returns None if key is absent
7print(config.get("user")) # None
8print(config.get("user", "root")) # root (custom default)
9
10# Check membership before accessing
11if "port" in config:
12 print(config["port"]) # 5432
13
14# KeyError example
15config["missing"] # KeyError: 'missing'

setdefault()

.setdefault(key, default) returns the value if the key exists and inserts the default if it does not. It is the one-liner for the common pattern of "give me the value, or insert and return this default."

Python
1# Building a grouped dict without setdefault
2groups = {}
3items = [("fruit", "apple"), ("veg", "carrot"), ("fruit", "banana")]
4
5for category, item in items:
6 if category not in groups:
7 groups[category] = []
8 groups[category].append(item)
9
10# With setdefault: same result, less code
11groups = {}
12for category, item in items:
13 groups.setdefault(category, []).append(item)
14
15print(groups)
16# {'fruit': ['apple', 'banana'], 'veg': ['carrot']}

Try the lookup modes below. Keys marked with ? are not in the dict. Switch between d["key"] and d.get("key") to see the difference.

Dict Lookup Explorercreators = { python: ..., javascript: ..., ruby: ..., rust: ..., go: ... }
Try a key
pick a key above

Modifying dictionaries

Dicts are mutable. Assignment adds or updates a key. Deletion removes one. These operations are all O(1).

Python
1user = {"name": "Alice", "age": 30}
2
3# Add a new key
4user["email"] = "alice@example.com"
5
6# Update an existing key
7user["age"] = 31
8
9# Delete a key
10del user["email"]
11
12# pop() removes and returns the value
13age = user.pop("age")
14print(age) # 31
15print(user) # {'name': 'Alice'}
16
17# pop() with a default avoids KeyError
18role = user.pop("role", "viewer")
19print(role) # viewer

Merging dicts

Python 3.9 introduced the | operator for merging two dicts into a new one. The |= operator updates in place. For older Python, use .update(). In all cases, the right-hand dict wins on key conflicts.

Python
1defaults = {"theme": "dark", "lang": "en", "timeout": 30}
2overrides = {"lang": "fr", "timeout": 60}
3
4# | creates a new dict (Python 3.9+)
5config = defaults | overrides
6print(config)
7# {'theme': 'dark', 'lang': 'fr', 'timeout': 60}
8
9# .update() modifies in place (all Python versions)
10defaults.update(overrides)
11print(defaults)
12# {'theme': 'dark', 'lang': 'fr', 'timeout': 60}
13
14# Original defaults are modified, use | if you need to preserve them

Useful methods

Python
1d = {"a": 1, "b": 2, "c": 3}
2
3# popitem() removes and returns the last inserted item
4key, val = d.popitem()
5print(key, val) # c 3
6
7# clear() empties the dict
8d.clear()
9print(d) # {}
10
11# copy() creates a shallow copy
12original = {"x": [1, 2]}
13shallow = original.copy()
14shallow["x"].append(3)
15print(original) # {'x': [1, 2, 3]}: inner list is shared

Iterating

Dictionaries have three view objects: .keys(), .values(), and .items(). They are live, they reflect any changes to the dict without being rebuilt. Iterating a dict directly gives you its keys, same as .keys().

Python
1scores = {"Alice": 92, "Bob": 78, "Carol": 85}
2
3# Keys (default)
4for name in scores:
5 print(name) # Alice, Bob, Carol
6
7# Values
8for score in scores.values():
9 print(score) # 92, 78, 85
10
11# Both: .items() is the most common loop
12for name, score in scores.items():
13 print(f"{name}: {score}")

Sorted iteration

Python 3.7+ guarantees that dicts preserve insertion order. If you need a different order, wrap the iteration in sorted(). The dict itself is never modified.

Python
1scores = {"Alice": 92, "Bob": 78, "Carol": 85}
2
3# Sort by name (alphabetical)
4for name, score in sorted(scores.items()):
5 print(f"{name}: {score}")
6
7print()
8
9# Sort by score (descending)
10for name, score in sorted(scores.items(), key=lambda item: item[1], reverse=True):
11 print(f"{name}: {score}")

Never modify during iteration

Adding or removing keys while iterating raises a RuntimeError. If you need to filter a dict, iterate a copy of the keys or build a new dict.

Python
1scores = {"Alice": 92, "Bob": 45, "Carol": 85, "Dan": 38}
2
3# Remove failing scores: iterate a snapshot of keys
4for name in list(scores.keys()):
5 if scores[name] < 50:
6 del scores[name]
7
8print(scores) # {'Alice': 92, 'Carol': 85}
9
10# Or build a new dict with a comprehension (cleaner)
11passing = {name: s for name, s in scores.items() if s >= 50}
12print(passing) # {'Alice': 92, 'Carol': 85}

Dict comprehensions

A dict comprehension builds a new dictionary in a single expression, the same way a list comprehension builds a list. The syntax is { key: value for item in iterable }.

Python
1# Square each number
2squares = {n: n ** 2 for n in range(6)}
3print(squares)
4# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
5
6# Map a list to a dict
7names = ["Alice", "Bob", "Carol"]
8lengths = {name: len(name) for name in names}
9print(lengths)
10# {'Alice': 5, 'Bob': 3, 'Carol': 5}

With a filter condition

Python
1scores = {"Alice": 92, "Bob": 45, "Carol": 85, "Dan": 38}
2
3# Keep only passing scores
4passing = {name: s for name, s in scores.items() if s >= 50}
5print(passing)
6# {'Alice': 92, 'Carol': 85}

Inverting a dict

Swapping keys and values is a one-liner with a comprehension. It only works correctly when all values are unique and hashable.

Python
1country_capital = {
2 "France": "Paris",
3 "Germany": "Berlin",
4 "Japan": "Tokyo",
5}
6
7capital_country = {v: k for k, v in country_capital.items()}
8print(capital_country)
9# {'Paris': 'France', 'Berlin': 'Germany', 'Tokyo': 'Japan'}
10
11print(capital_country["Tokyo"]) # Japan

Building from two lists

Python
1keys = ["host", "port", "user"]
2values = ["localhost", 5432, "admin"]
3
4config = {k: v for k, v in zip(keys, values)}
5print(config)
6# {'host': 'localhost', 'port': 5432, 'user': 'admin'}
7
8# dict(zip(...)) is equivalent and slightly shorter
9config = dict(zip(keys, values))
10print(config)
11# {'host': 'localhost', 'port': 5432, 'user': 'admin'}

defaultdict and Counter

The collections module ships two dict subclasses that handle the most common dict patterns with less boilerplate.

defaultdict

A defaultdict never raises a KeyError. When you access a missing key, it calls a factory function to create the default value and inserts it automatically. The factory is any callable: list, int, set, or a lambda.

Python
1from collections import defaultdict
2
3# Group items by category
4items = [("fruit", "apple"), ("veg", "carrot"), ("fruit", "banana")]
5
6# Without defaultdict: requires setdefault or an if-check
7groups = {}
8for cat, item in items:
9 groups.setdefault(cat, []).append(item)
10
11# With defaultdict: cleaner
12groups = defaultdict(list)
13for cat, item in items:
14 groups[cat].append(item) # no KeyError, list created automatically
15
16print(dict(groups))
17# {'fruit': ['apple', 'banana'], 'veg': ['carrot']}
18
19# defaultdict(int) for counting
20text = "hello world"
21freq = defaultdict(int)
22for char in text:
23 freq[char] += 1 # starts at 0 automatically
24
25print(dict(freq))

Counter

Counter is a dict subclass built for counting. Pass it any iterable and it tallies the occurrences of each element. The .most_common(n) method returns the top n items sorted by frequency.

Python
1from collections import Counter
2
3words = "the cat sat on the mat the cat".split()
4c = Counter(words)
5
6print(c)
7# Counter({'the': 3, 'cat': 2, 'sat': 1, 'on': 1, 'mat': 1})
8
9print(c.most_common(2))
10# [('the', 3), ('cat', 2)]
11
12print(c["cat"]) # 2
13print(c["dog"]) # 0: missing keys return 0, no KeyError
14
15# Counters support arithmetic
16a = Counter("aab")
17b = Counter("abc")
18print(a + b) # Counter({'a': 3, 'b': 2, 'c': 1})
19print(a - b) # Counter({'a': 1}): removes non-positive counts

See Counter in action below. Pick a text and toggle between word and character frequency.

Counter Explorerfrom collections import Counter
Pick a text
Counter(text.split()).most_common(10)
is
3
better
3
than
3
beautiful
1
ugly
1
explicit
1
implicit
1
simple
1
complex
1

Real-world patterns

Grouping records

The most common dict pattern: start with a flat list, group it by some attribute. A defaultdict(list) or setdefault both work.

Python
1from collections import defaultdict
2
3employees = [
4 {"name": "Alice", "dept": "Engineering"},
5 {"name": "Bob", "dept": "Design"},
6 {"name": "Carol", "dept": "Engineering"},
7 {"name": "Dan", "dept": "Design"},
8]
9
10by_dept = defaultdict(list)
11for e in employees:
12 by_dept[e["dept"]].append(e["name"])
13
14print(dict(by_dept))
15# {'Engineering': ['Alice', 'Carol'], 'Design': ['Bob', 'Dan']}

Memoization / caching

Store results you have already computed so you never redo expensive work. A dict keyed by the function's arguments is the simplest cache.

Python
1def fib(n: int, cache: dict = {}) -> int:
2 if n in cache:
3 return cache[n]
4 if n <= 1:
5 return n
6 result = fib(n - 1) + fib(n - 2)
7 cache[n] = result
8 return result
9
10print([fib(i) for i in range(10)])
11# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
12
13# functools.lru_cache does this automatically and is preferred
14from functools import lru_cache
15
16@lru_cache(maxsize=None)
17def fib2(n: int) -> int:
18 if n <= 1:
19 return n
20 return fib2(n - 1) + fib2(n - 2)

Safe nested access

Deeply nested dicts are common in API responses. Chaining .get() avoids KeyError at every level.

Python
1response = {
2 "user": {
3 "profile": {
4 "city": "Berlin",
5 }
6 }
7}
8
9# Unsafe: any missing key raises KeyError
10# city = response["user"]["profile"]["city"]
11
12# Safe: returns None at the first missing key
13city = (response
14 .get("user", {})
15 .get("profile", {})
16 .get("city"))
17
18print(city) # Berlin
19
20missing = (response
21 .get("user", {})
22 .get("address", {})
23 .get("zip"))
24
25print(missing) # None

Dispatch table

Replace long if-elif chains with a dict that maps keys to callables. Cleaner, easier to extend, and lookups are O(1).

Python
1def handle_create(data): return f"created {data}"
2def handle_update(data): return f"updated {data}"
3def handle_delete(data): return f"deleted {data}"
4
5handlers = {
6 "CREATE": handle_create,
7 "UPDATE": handle_update,
8 "DELETE": handle_delete,
9}
10
11def dispatch(action: str, data: str) -> str:
12 handler = handlers.get(action)
13 if handler is None:
14 return f"unknown action: {action}"
15 return handler(data)
16
17print(dispatch("CREATE", "user-42")) # created user-42
18print(dispatch("DELETE", "post-7")) # deleted post-7
19print(dispatch("RESET", "db")) # unknown action: RESET