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 = {}Why dicts are fast
Section titled “Why dicts are fast”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.
Why immutable?
Section titled “Why immutable?”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 hashablehash([1, 2]) # TypeError: unhashable type: 'list'
d = {}d[(1, 2)] = "ok" # a tuple key is fined[[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 → hashabled[{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)) # workshash((1, [2, 3])) # TypeError — the inner list is mutableCore operations
Section titled “Core operations”d = {"a": 1, "b": 2, "c": 3}
# ── Read ──────────────────────────────────────────d["a"] # 1 — direct hash lookup. O(1) # KeyError if the key is missingd.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 99scores["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 missingscores["carol"] = scores.get("carol", 0) + 1 # carol doesn't exist → 0 + 1 = 1scores["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.
Big O reference
Section titled “Big O reference”| Operation | Cost | Note |
|---|---|---|
d[key] read/write | O(1) | hash jump, no scan |
key in d | O(1) | membership on keys |
d.get(key) | O(1) | safe read |
del d[key] / pop | O(1) | hash jump then remove |
len(d) | O(1) | stored as metadata |
| iteration | O(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 |
Dict comprehensions
Section titled “Dict comprehensions”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 linesUnpacking pairs with .items()
Section titled “Unpacking pairs with .items()”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 gradecurved = {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"}Filtering while building
Section titled “Filtering while building”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 squareseven_squares = {x: x**2 for x in range(10) if x % 2 == 0}# → {0: 0, 2: 4, 4: 16, 6: 36, 8: 64}Building from a list of pairs
Section titled “Building from a list of pairs”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 dictd = {k: v for k, v in pairs}# → {"a": 1, "b": 2, "c": 3}
# dict() does the same without a comprehension when no transformation is neededd = 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 lettergroups = {}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 gonegroups = defaultdict(list) # missing key → a fresh empty listfor 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: counting done for you
Section titled “Counter: counting done for you”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"] # 3counts["missing"] # 0 — never a KeyErrorcounts.most_common(2) # [("apple", 3), ("banana", 2)] — top N by countCounter 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})Merging dicts
Section titled “Merging dicts”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 | operatormerged = 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 versionsmerged = {**defaults, **overrides}# same result: overrides is unpacked last, so it winsBoth 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 onceBoth 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.
The unpacking operators * and **
Section titled “The unpacking operators * and **”{**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 itemWith *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)Tips & tricks for efficient dicts
Section titled “Tips & tricks for efficient dicts”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) totalusers = [{"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) foreverby_id = {u["id"]: u for u in users}by_id[2] # {"id": 2, "name": "Bo"} — instant2. 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 lookupcount = 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 dancegroups.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 passfor k in d: print(k, d[k]) # d[k] re-hashes k
# Efficient — value comes free with the keyfor 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.