Dicts and Sets
Dicts — fast key-value lookup
Section titled “Dicts — fast key-value lookup”A dict maps hashable keys to arbitrary values. Since Python 3.7 the insertion order of keys is guaranteed and preserved.
scores = {'alice': 95, 'bob': 87}scores['carol'] = 92 # add a new keyprint(scores['alice']) # 95del scores['bob'] # remove a keyprint(scores) # {'alice': 95, 'carol': 92}Safe access: get, setdefault, and defaultdict
Section titled “Safe access: get, setdefault, and defaultdict”Accessing a missing key with [] raises KeyError.
Use .get() for a safe read with an optional default, or .setdefault() to insert a default value when the key is absent — it does not overwrite an existing value.
scores = {'alice': 95, 'bob': 87}unknown = scores.get('dave', 0) # returns 0 — no KeyErrorscores.setdefault('eve', 88) # inserts eve=88 only if absentprint(scores)# {'alice': 95, 'bob': 87, 'eve': 88}When every missing key should get a default — not just the first access — collections.defaultdict is less code than a repeated setdefault:
from collections import defaultdictcounts = defaultdict(int)for word in ['a', 'b', 'a', 'c', 'b', 'a']: counts[word] += 1print(dict(counts)) # {'a': 3, 'b': 2, 'c': 1}Iterating with .items()
Section titled “Iterating with .items()”.items() yields (key, value) pairs, which you can unpack directly in a for loop.
for name, score in scores.items(): print(f'{name}: {score}')You can also iterate over .keys() (default) or .values() independently.
Dict views are live, not a snapshot
Section titled “Dict views are live, not a snapshot”.keys(), .values(), and .items() return view objects — they reflect the dict as it is right now, every time you look at them, rather than freezing a copy at call time:
d = {'a': 1, 'b': 2}keys_view = d.keys()d['c'] = 3print(list(keys_view)) # ['a', 'b', 'c'] — the view saw the later insertIf you need a frozen snapshot to iterate over safely while also modifying the dict, wrap the view in list(...) — that copies the keys now, once.
Merging dicts: | and |=
Section titled “Merging dicts: | and |=”Since Python 3.9, | merges two dicts into a new dict (later operand’s keys win on conflict), and |= merges the right-hand dict into the left-hand one in place:
a = {'x': 1, 'y': 2}b = {'y': 99, 'z': 3}merged = a | bprint(merged) # {'x': 1, 'y': 99, 'z': 3} — new dict, b's y winsprint(a) # {'x': 1, 'y': 2} — a itself is untouched by |
a |= bprint(a) # {'x': 1, 'y': 99, 'z': 3} — now a is updated in place| is the modern replacement for {**a, **b}; |= is the modern replacement for a.update(b).
Dict comprehensions
Section titled “Dict comprehensions”Like list comprehensions, dict comprehensions build a new dict in one expression.
squared = {n: n**2 for n in range(1, 6)}# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}Hashability: what is allowed to be a dict key
Section titled “Hashability: what is allowed to be a dict key”A key must be hashable, which means it needs a __hash__ that stays consistent with __eq__ (two keys that compare equal must hash equal). list has no __hash__ — it is mutable, so its hash could never stay valid — and a tuple containing a list inherits the same problem:
bad_key = {[1, 2]: 'value'}# TypeError: cannot use 'list' as a dict key (unhashable type: 'list')bad_key2 = {(1, [2, 3]): 'value'}# TypeError: cannot use 'tuple' as a dict key (unhashable type: 'list')str, int, float, bool, tuple-of-hashables, and frozenset are all safe keys.
Counter: a dict specialised for counting
Section titled “Counter: a dict specialised for counting”collections.Counter is a dict subclass built for counting hashable items, with arithmetic and a .most_common() ranking built in:
from collections import Counterletters = Counter('abracadabra')print(letters) # Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})print(letters.most_common(2)) # [('a', 5), ('b', 2)]Insertion order vs equality
Section titled “Insertion order vs equality”Insertion order is guaranteed for iteration (.keys(), .values(), .items(), for key in d) — but two dicts still compare equal if they have the same keys and values, regardless of order:
d1 = {'a': 1, 'b': 2}d2 = {'b': 2, 'a': 1}print(d1 == d2) # True — equality ignores insertion orderDo not rely on order to mean “these dicts are different” — check == for equality and iterate for order.
Mutating a dict while iterating over it
Section titled “Mutating a dict while iterating over it”Changing a dict’s size (adding or removing a key) while a loop is iterating over it raises RuntimeError — CPython detects the size change and refuses to guess what should happen next:
d = {'a': 1, 'b': 2, 'c': 3}for key in d: if key == 'b': del d[key]# RuntimeError: dictionary changed size during iterationIterate over a snapshot of the keys instead — list(d) copies the keys once, up front, so deleting from d during the loop no longer changes what the loop is iterating over:
d = {'a': 1, 'b': 2, 'c': 3}for key in list(d): if key == 'b': del d[key]print(d) # {'a': 1, 'c': 3}Reassigning an existing key’s value (not adding or removing one) does not change the dict’s size, so it does not trigger this error.
KeyError handling: pick one style and stay consistent
Section titled “KeyError handling: pick one style and stay consistent”Three equally valid ways to handle a possibly-missing key — mixing all three in one codebase is what makes AI-generated code hard to trust:
inventory = {'apples': 10}for item in ['apples', 'bananas']: if item in inventory: # LBYL: check first print(inventory[item]) else: print('out of stock').get(item, default) (EAFP-adjacent, one expression) and try: ... except KeyError: (true EAFP) are the other two — reach for .get() when a default value is enough, and try/except when a missing key is exceptional and you want to react differently than “use a placeholder.”
Sets — unique elements and set algebra
Section titled “Sets — unique elements and set algebra”A set is an unordered collection of unique hashable elements.
It is optimised for in membership tests (O(1) average) and supports mathematical set operations.
a = {1, 2, 3, 4}b = {3, 4, 5, 6}
print(a | b) # union: {1, 2, 3, 4, 5, 6}print(a & b) # intersection: {3, 4}print(a - b) # difference: {1, 2}print(a ^ b) # symmetric diff: {1, 2, 5, 6}Use frozenset for an immutable set — because it never changes, it is itself hashable, so it can be a dict key or live inside another set:
fs = frozenset([1, 2, 3])group_lookup = {fs: 'group-a'} # a plain set could never do thisfs.add(4) # AttributeError: 'frozenset' object has no attribute 'add'Full runnable demo
Section titled “Full runnable demo”# --- dict safe access ---scores = {'alice': 95, 'bob': 87}scores['carol'] = 92print('scores:', scores)
unknown = scores.get('dave', 0)print('dave score:', unknown)
scores.setdefault('eve', 88)print('after setdefault:', scores)
for name, score in scores.items(): print(f' {name}: {score}')
# --- dict comprehension ---squared = {n: n**2 for n in range(1, 6)}print('squared:', squared)
# --- set algebra ---a = {1, 2, 3, 4}b = {3, 4, 5, 6}print('union:', sorted(a | b))print('intersection:', sorted(a & b))print('difference:', sorted(a - b))print('symmetric diff:', sorted(a ^ b))Loading Python runtime (first run only)…
The playground below runs the depth topics above — merging with |/|=, live views, defaultdict, the hashability contract, Counter, insertion order vs equality, mutate-while-iterate’s RuntimeError, and frozenset:
# --- dict | and |= (3.9+) ---a = {'x': 1, 'y': 2}b = {'y': 99, 'z': 3}merged = a | bprint('merged (new dict):', merged)print('a unchanged:', a)
a |= bprint('a after |=:', a)
# --- dict views are live, not a snapshot ---d = {'a': 1, 'b': 2}keys_view = d.keys()print('view before:', list(keys_view))d['c'] = 3print('view after adding c:', list(keys_view))
# --- d[k] vs .get vs .setdefault vs defaultdict ---scores = {'alice': 95}try: scores['bob']except KeyError as e: print('KeyError:', e)
print('get missing:', scores.get('bob', 0))scores.setdefault('bob', 80)print('after setdefault:', scores)scores.setdefault('alice', 999)print('setdefault does not overwrite existing:', scores)
from collections import defaultdictcounts = defaultdict(int)for word in ['a', 'b', 'a', 'c', 'b', 'a']: counts[word] += 1print('defaultdict counts:', dict(counts))
# --- hashability contract: __hash__ + __eq__ ---try: bad_key = {[1, 2]: 'value'}except TypeError as e: print('TypeError:', e)
try: bad_key2 = {(1, [2, 3]): 'value'}except TypeError as e: print('TypeError:', e)
# --- Counter ---from collections import Counterletters = Counter('abracadabra')print('Counter:', letters)print('most common 2:', letters.most_common(2))
# --- insertion order since 3.7; dict equality ignores order ---d1 = {'a': 1, 'b': 2}d2 = {'b': 2, 'a': 1}print('d1 == d2 (order ignored):', d1 == d2)print('d1 keys order:', list(d1.keys()))print('d2 keys order:', list(d2.keys()))
# --- mutate a dict while iterating over it -> RuntimeError ---d3 = {'a': 1, 'b': 2, 'c': 3}try: for key in d3: if key == 'b': del d3['b']except RuntimeError as e: print('RuntimeError:', e)
# Safe pattern: iterate over a snapshot of the keysd4 = {'a': 1, 'b': 2, 'c': 3}for key in list(d4): if key == 'b': del d4['b']print('safe delete result:', d4)
# --- frozenset: hashable, usable as dict key or set element ---fs = frozenset([1, 2, 3])group_lookup = {fs: 'group-a'}print('frozenset as key:', group_lookup)try: fs.add(4)except AttributeError as e: print('AttributeError:', e)
# --- KeyError handling: try/except vs .get vs 'in' check ---inventory = {'apples': 10}for item in ['apples', 'bananas']: if item in inventory: print(f'{item}: {inventory[item]}') else: print(f'{item}: out of stock')Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”flowchart LR
subgraph Dict["dict buckets (hash table)"]
H0["hash('alice') % n -> bucket 0"] --> KV0["'alice': 95"]
H1["hash('bob') % n -> bucket 1"] --> KV1["'bob': 87"]
H2["hash('carol') % n -> bucket 2"] --> KV2["'carol': 92"]
end
Lookup["scores['bob']"] -->|hash then compare with ==| H1 Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
dict lookup by key | O(1) average read/write regardless of size | every key must be hashable — no list keys |
dict view (.keys()/.items()) | no copy — always reflects the live dict | mutating the dict’s size while iterating a view raises RuntimeError |
defaultdict | no if key not in d boilerplate | a plain read (d[missing_key]) silently creates the key with the default |
set / frozenset membership | O(1) average in check | unordered — no indexing, no guaranteed iteration order |
frozenset over set | hashable — usable as a dict key or set element | immutable — no .add()/.remove() after creation |
Common Misconceptions
Section titled “Common Misconceptions”- “
a.setdefault(k, v)always sets the value tov” — fact: it only setsvwhenkis absent; ifkalready exists, the existing value is kept and returned. - “
d.keys()is a list, a fixed snapshot at the time you called it” — fact: it’s a live view — later inserts todare visible through the same view object. - “Any object can be a dict key” — fact: only hashable objects can — mutable containers (
list, and atupleholding one) raiseTypeError. - “Dicts with the same keys inserted in a different order are different dicts” — fact:
==ignores insertion order; only iteration order differs. - “You can delete a key from a dict while looping over it, as long as you’re careful” — fact: changing a dict’s size during iteration always raises
RuntimeError, regardless of care; iterate overlist(d)instead.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a loop do
for key in some_dict:and thendel some_dict[key](or add a new key) inside the loop? That is a guaranteedRuntimeError: dictionary changed size during iteration— look forlist(some_dict)in theforline instead. - Does the code use
dict[key]where the key might be absent, with no surroundingtry/except,.get(), orincheck? That is an unguardedKeyErrorwaiting for the first cache miss or malformed input. - Is a
list, or atuplecontaining alist, ever used (or about to be used) as a dict key or set element? That raisesTypeError: unhashable typeimmediately. - Does the code assume
dictiteration order reflects when values were last updated rather than when keys were first inserted? Re-assigning an existing key’s value does not move it in iteration order. - Does
defaultdict(int)(or similar) get read withd[key]in a context where “does this key exist” actually matters? A plain read silently creates the key with the default value — that read is not free of side effects.
🐛 Spot the bug
def purge_expired(cache, now): for key, expires_at in cache.items(): if expires_at < now: del cache[key] return cache
data = {'a': 1, 'b': 5, 'c': 2}print(purge_expired(data, now=3))Show the answer
Deleting a key from cache while a for loop is iterating over cache.items() changes the dict’s size mid-iteration. CPython detects this and raises RuntimeError: dictionary changed size during iteration — verified on Python 3.14, the traceback points at the for line itself, not the del. The fix is to iterate over a snapshot of the items instead: for key, expires_at in list(cache.items()):, or build a list of keys to delete first and delete them in a second pass after the loop ends.