Skip to content

Dictionaries

A dict maps unique keys to values. Think of it as a real dictionary: you look up a word (the key) and get its definition (the value). Since Python 3.7 the insertion order is guaranteed. Iterate a dict and you get the keys back in the order you added them.

person = {"name": "Alice", "age": 30, "city": "Rome"}
empty = {}

A dict is backed by a hash table. When you write d["age"], Python runs the key through a hash function to compute where in memory the value sits, then jumps straight there. It does not scan the other keys. That is why lookup is O(1), constant time, no matter how many pairs the dict holds. A list, by contrast, has to walk element by element (O(n)) to find a value.

The only requirement: keys must be hashable. In practice this means immutable. Strings, numbers, and tuples work as keys; lists, sets, and other dicts do not.

The dict decides where to store a pair from the key’s hash. If the key could change after you inserted it, its hash would change too, and the dict would go looking in the wrong slot on the next lookup. The pair would still be sitting in the old slot, now unreachable. To rule that out, Python only accepts keys whose value cannot change, so their hash is fixed for life.

That is exactly why a list cannot be a key. A list is mutable: you can append to it or reassign an element, which would move the hash out from under the dict. Python blocks the problem at the source by refusing to hash a list at all.

hash((1, 2)) # works → tuples are immutable, so hashable
hash([1, 2]) # TypeError: unhashable type: 'list'
d = {}
d[(1, 2)] = "ok" # a tuple key is fine
d[[1, 2]] = "no" # TypeError: unhashable type: 'list'

The rule extends to anything mutable: list, set, and dict are all unhashable and cannot be keys. Their immutable counterparts can: use a tuple instead of a list, or a frozenset instead of a set.

d = {}
d[frozenset({1, 2})] = "ok" # frozenset is immutable → hashable
d[{1, 2}] = "no" # TypeError: unhashable type: 'set'

One subtlety: a tuple is only hashable if everything inside it is too. A tuple that contains a list is itself unhashable, because that inner list could still change.

hash((1, 2, 3)) # works
hash((1, [2, 3])) # TypeError — the inner list is mutable
d = {"a": 1, "b": 2, "c": 3}
# ── Read ──────────────────────────────────────────
d["a"] # 1 — direct hash lookup. O(1)
# KeyError if the key is missing
d.get("z") # None — safe read, never raises. O(1)
d.get("z", 0) # 0 — supply your own default. O(1)
# ── Write ─────────────────────────────────────────
d["d"] = 4 # add a new pair OR overwrite existing. O(1)
d.update({"e": 5}) # merge another dict in. O(k)
d |= {"f": 6} # Python 3.9+: same as update. O(k)
# ── Update / increment ────────────────────────────
scores = {"alice": 10, "bob": 7}
scores["alice"] = 99 # overwrite: alice goes from 10 to 99
scores["alice"] += 5 # increment: works only if the key exists
# raises KeyError if "alice" is not in the dict
# Safe increment: use get() to default to 0 when the key is missing
scores["carol"] = scores.get("carol", 0) + 1 # carol doesn't exist → 0 + 1 = 1
scores["bob"] = scores.get("bob", 0) + 1 # bob exists → 7 + 1 = 8
# ── Delete ────────────────────────────────────────
del d["a"] # remove by key. KeyError if missing. O(1)
d.pop("b") # remove AND return the value. O(1)
d.pop("z", None)# safe pop — returns None if missing. O(1)
# ── Iterate ───────────────────────────────────────
for key in d: ... # keys (default). O(n)
for val in d.values(): ... # values. O(n)
for k, v in d.items(): ... # both at once. O(n)
# ── Check ─────────────────────────────────────────
"a" in d # True — membership test on KEYS. O(1)
len(d) # number of pairs. O(1)

The key takeaway from the right-hand column: almost everything a dict does to a single key is O(1). Only operations that touch every pair (iteration, merging) scale with size.

OperationCostNote
d[key] read/writeO(1)hash jump, no scan
key in dO(1)membership on keys
d.get(key)O(1)safe read
del d[key] / popO(1)hash jump then remove
len(d)O(1)stored as metadata
iterationO(n)visits every pair once
d.update(other)O(k)k = size of the other dict
value in d.values()O(n)values are not indexed: full scan

A dict comprehension builds a dictionary from any iterable in one expression. The syntax mirrors list comprehensions, but uses curly braces and a key: value pair instead of a single value.

# General shape:
# {key_expression: value_expression for item in iterable}
squares = {x: x**2 for x in range(6)}
# → {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

This is equivalent to the loop below, but more concise:

squares = {}
for x in range(6):
squares[x] = x**2 # same result, more lines

When you already have a dict and want to transform it, .items() gives you (key, value) pairs to unpack:

grades = {"Alice": 90, "Bob": 75, "Carol": 88}
# Add a 10-point bonus to every grade
curved = {name: score + 10 for name, score in grades.items()}
# → {"Alice": 100, "Bob": 85, "Carol": 98}
# Swap keys and values (only safe when values are unique)
inverted = {score: name for name, score in grades.items()}
# → {90: "Alice", 75: "Bob", 88: "Carol"}

Add an if condition at the end to skip items that do not match:

grades = {"Alice": 90, "Bob": 55, "Carol": 88, "Dan": 40}
# Keep only students who passed (score >= 60)
passed = {name: score for name, score in grades.items() if score >= 60}
# → {"Alice": 90, "Carol": 88}
# Keep only even squares
even_squares = {x: x**2 for x in range(10) if x % 2 == 0}
# → {0: 0, 2: 4, 4: 16, 6: 36, 8: 64}

Any iterable of two-element sequences works, not just dicts:

pairs = [("a", 1), ("b", 2), ("c", 3)]
# Turn a list of (key, value) tuples into a dict
d = {k: v for k, v in pairs}
# → {"a": 1, "b": 2, "c": 3}
# dict() does the same without a comprehension when no transformation is needed
d = dict(pairs)
# → {"a": 1, "b": 2, "c": 3}

defaultdict: no more “does the key exist?”

Section titled “defaultdict: no more “does the key exist?””

A normal dict raises KeyError on a missing key. A defaultdict instead calls a factory function you provide and inserts that default automatically. This kills the most common dict boilerplate.

from collections import defaultdict
# Goal: group the words into lists by their first letter, turning
# ["apple", "avocado", "banana"]
# into
# {"a": ["apple", "avocado"], "b": ["banana"]}
# Plain dict: you must create the list the first time you see a letter
groups = {}
for word in ["apple", "avocado", "banana"]:
key = word[0] # word is a string; word[0] is its first letter
if key not in groups: # first word for this letter?
groups[key] = [] # then start an empty list for it
groups[key].append(word) # now it is safe to append
# defaultdict: a missing key auto-creates the empty list, so the check is gone
groups = defaultdict(list) # missing key → a fresh empty list
for word in ["apple", "avocado", "banana"]:
groups[word[0]].append(word) # word[0] = first letter; just append
# both versions produce:
# {"a": ["apple", "avocado"], "b": ["banana"]}

print(groups) shows the type prefix (defaultdict(<class 'list'>, {...})). To print just the data, convert first:

print(dict(groups))
# {'a': ['apple', 'avocado'], 'b': ['banana']}

The factory is any zero-argument callable:

defaultdict(int) # missing key → 0 (great for counting)
defaultdict(list) # missing key → [] (great for grouping)
defaultdict(set) # missing key → set()
defaultdict(dict) # missing key → {} (nested dicts)
hits = defaultdict(int)
hits["page_a"] += 1 # starts from 0, becomes 1 (no KeyError)

Counter is a dict subclass built for tallying. Feed it an iterable and it counts occurrences. Looking up a key that is not present returns 0 instead of raising a KeyError, so you can read any key without checking first.

from collections import Counter
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = Counter(words)
# Counter({"apple": 3, "banana": 2, "cherry": 1})
counts["apple"] # 3
counts["missing"] # 0 — never a KeyError
counts.most_common(2) # [("apple", 3), ("banana", 2)] — top N by count

Counter even supports arithmetic, which makes combining tallies trivial:

a = Counter({"x": 3, "y": 1})
b = Counter({"x": 1, "y": 2, "z": 1})
a + b # add counts → Counter({"x": 4, "y": 3, "z": 1})
a - b # subtract → Counter({"x": 2}) (zero/negative dropped)
a & b # intersection min → Counter({"x": 1, "y": 1})
a | b # union max → Counter({"x": 3, "y": 2, "z": 1})

There are two ways to combine dicts into a new one.

defaults = {"color": "blue", "size": "M"}
overrides = {"size": "L", "weight": "light"}
# Python 3.9+ : the | operator
merged = defaults | overrides
# {"color": "blue", "size": "L", "weight": "light"}
# ^ on a shared key the right side wins
# Python 3.5+ : dict unpacking, also works on older versions
merged = {**defaults, **overrides}
# same result: overrides is unpacked last, so it wins

Both build a brand-new dict and leave the originals untouched. On modern Python | reads more clearly. Reach for {**a, **b} when you need to run on Python older than 3.9, or when you want to drop in an extra key while merging:

config = {**defaults, "debug": True} # merge and add a key at once

Both forms are O(n + m) in time and space, where n and m are the sizes of the two dicts. Every pair from both inputs is copied into the fresh dict, and each copy is a constant-time insertion.

{**a, **b} is one example of a more general idea called unpacking. Unpacking takes a collection and pours out its items one by one, as if you had typed each item yourself instead of passing the whole collection.

The clearest way to see it is with and without the star:

nums = [2, 3]
[1, *nums, 4] # [1, 2, 3, 4] nums is poured in
[1, nums, 4] # [1, [2, 3], 4] nums stays as one item

With *nums, the elements 2 and 3 land directly in the new list. Without it, the list nums goes in as a single nested element.

There are two operators. Use * on a sequence (a list, tuple, or set) and ** on a mapping (a dict, whose items come in key/value pairs). They show up in three places.

Building new collections by pouring existing ones into a fresh literal:

a = [1, 2]
b = [3, 4]
[*a, *b] # [1, 2, 3, 4] list O(n+m)
(*a, *b) # (1, 2, 3, 4) tuple O(n+m)
{*a, *b} # {1, 2, 3, 4} set (dedup) O(n+m)
{**d1, **d2} # merge dicts, d2 wins O(n+m)

Each of these copies every element from the inputs into a new object, so the cost grows with the total number of elements spread: O(n + m) for two inputs, O(k) for k of them.

Passing arguments to a function. * spreads a sequence into positional arguments, ** spreads a dict into keyword arguments:

def point(x, y, z):
...
coords = [1, 2, 3]
point(*coords) # same call as point(1, 2, 3)
kw = {"x": 1, "y": 2, "z": 3}
point(**kw) # same call as point(x=1, y=2, z=3)

Collecting arguments in a function definition. The mirror image: *args gathers any extra positional arguments into a tuple, **kwargs gathers any extra keyword arguments into a dict:

def f(*args, **kwargs):
print(args) # tuple of the positional arguments
print(kwargs) # dict of the keyword arguments
f(1, 2, mode="fast")
# args = (1, 2)
# kwargs = {"mode": "fast"}

In a function call, spreading also costs O(k) in the number of arguments handed over, and *args / **kwargs cost O(k) to gather them back up. For a normal call the argument count is small, so this is effectively constant.

A single * also appears in assignment, where it grabs “the rest” of a sequence (see the Tuples section):

first, *rest = [1, 2, 3, 4]
# first = 1, rest = [2, 3, 4] rest is a new list: O(n)

1. Replace list scans with dict lookups. This is the change that saves the most time in practice. If you loop through a list to find matching items, a dict turns each O(n) search into O(1).

# Slow — O(n) lookup inside a loop → O(n·m) total
users = [{"id": 1, "name": "Al"}, {"id": 2, "name": "Bo"}]
def find(uid):
for u in users: # scans every time
if u["id"] == uid:
return u
# Fast — build an index once, then O(1) forever
by_id = {u["id"]: u for u in users}
by_id[2] # {"id": 2, "name": "Bo"} — instant

2. Use dict.get with a default instead of if key in d. One lookup instead of two, and cleaner.

# Two lookups: one for `in`, one for `[key]`
count = d[key] if key in d else 0
# One lookup
count = d.get(key, 0)

3. Use setdefault to read-or-initialise in one call (when a full defaultdict is overkill):

# Instead of the if-not-in dance
groups.setdefault(key, []).append(value)

4. Iterate .items() once, not the dict twice. Reading the value through d[key] inside a for key in d loop is a second, wasted hash lookup per iteration.

# Wasteful — a lookup on every pass
for k in d:
print(k, d[k]) # d[k] re-hashes k
# Efficient — value comes free with the key
for k, v in d.items():
print(k, v)

5. Deduplicate while preserving order with dict.fromkeys (dicts keep insertion order, and keys are unique):

data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
unique = list(dict.fromkeys(data)) # [3, 1, 4, 5, 9, 2, 6]

6. Reach for Counter and defaultdict before hand-rolling. They are written in C and handle the edge cases for you, and they are almost always faster and shorter than a manual dict with if guards.