08 — Dictionaries & Sets
Hash Table Internals — Open Addressing Since 3.6
# ── CPython 3.6+ dict implementation: compact hash tables ──
# Unlike traditional hash maps (separate chaining with linked lists),
# CPython uses OPEN ADDRESSING with a two-array layout:
# 1. An index array (hash → slot) — sparse, ~1/3 to 2/3 full
# 2. A dense entries array (hash, key, value) — compact, insertion-ordered
# This is why dicts preserve insertion order AND use less memory than older dicts.
import sys
# The hash table's load factor triggers a resize — observable via size jumps
d = {}
prev_size = sys.getsizeof(d)
for i in range(20):
d[i] = i * 2
size = sys.getsizeof(d)
if size != prev_size:
print(f"resize at len={len(d):>3}: {prev_size} → {size} bytes")
prev_size = size
# Resize happens at ~2/3 load factor — capacity roughly doubles each time
# ── Hash collisions: equal values MUST hash equally ──
# Python's numeric tower: 1 == 1.0 == True are all equal, so they hash identically
print(hash(1) == hash(1.0) == hash(True)) # True — all three are the "same" key in a dict/set
print({1, 1.0, True}) # {1} — collapses to ONE element (first inserted wins)
# ANTI-PATTERN: using 1 and True (or 0 and False) as distinct dict keys
config = {1: "integer value", True: "boolean value"}
print(config) # {1: 'boolean value'} — True overwrote 1! They're the same key!
# ── Production: a bounded LRU cache built on dict ordering (3.7+) ──
# Insertion-order preservation makes OrderedDict/dict a natural LRU substrate
from collections import OrderedDict
class LRUCache:
"""Least-recently-used cache with O(1) get/set — production pattern."""
def __init__(self, capacity: int):
self.capacity = capacity
self._store: OrderedDict = OrderedDict()
def get(self, key):
"""Return value and mark as most-recently-used, or None on miss."""
if key not in self._store:
return None
self._store.move_to_end(key) # move to tail = most recently used
return self._store[key]
def put(self, key, value):
"""Insert/update key. Evicts LRU entry if over capacity."""
if key in self._store:
self._store.move_to_end(key) # update = also marks as MRU
self._store[key] = value
if len(self._store) > self.capacity:
self._store.popitem(last=False) # pop from HEAD = least recently used
cache = LRUCache(3)
cache.put("a", 1); cache.put("b", 2); cache.put("c", 3)
cache.get("a") # "a" is now MRU
cache.put("d", 4) # evicts "b" (LRU), not "a" (was accessed)
print(cache.get("b")) # None — evicted
print(cache.get("a")) # 1 — still present
Iterating dictionaries
scores = {"Ada": 98, "Grace": 95, "Alan": 87}
for key in scores: # iterates KEYS by default
print(key)
for key, value in scores.items(): # keys AND values
print(key, value)
for value in scores.values(): # values only
print(value)
print(list(scores.keys())) # ['Ada', 'Grace', 'Alan']
print(list(scores.values())) # [98, 95, 87]
print(list(scores.items())) # [('Ada', 98), ('Grace', 95), ('Alan', 87)]
Gotcha: .keys(), .values(), .items() return view objects, not lists — they stay live and reflect subsequent changes to the dict, and they don't support indexing.
d = {"a": 1, "b": 2}
keys_view = d.keys()
print(keys_view) # dict_keys(['a', 'b'])
d["c"] = 3
print(keys_view) # dict_keys(['a', 'b', 'c']) — the VIEW updated automatically!
# keys_view[0] # TypeError: 'dict_keys' object is not subscriptable
Merging Dictionaries
defaults = {"theme": "light", "notifications": True}
user_prefs = {"theme": "dark"}
# Python 3.9+ — the | merge operator (does NOT mutate either operand)
merged = defaults | user_prefs
print(merged) # {'theme': 'dark', 'notifications': True} — right side wins on conflict
# |= for in-place merge
settings = dict(defaults)
settings |= user_prefs
print(settings) # {'theme': 'dark', 'notifications': True}
# Pre-3.9 idiom — still extremely common in real code
merged_old = {**defaults, **user_prefs}
print(merged_old) # same result
# .update() — mutates the receiver in place
combined = dict(defaults)
combined.update(user_prefs)
print(combined)
defaultdict and Counter
Two of the most-used tools from collections for real-world data wrangling.
from collections import defaultdict, Counter
# defaultdict — auto-creates a default value for missing keys, avoiding
# verbose "if key not in d: d[key] = []" boilerplate
groups = defaultdict(list)
words = ["apple", "banana", "avocado", "blueberry", "cherry"]
for word in words:
groups[word[0]].append(word) # no KeyError, no manual initialization
print(dict(groups))
# {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}
# Counter — a dict subclass specialized for counting hashable items
counts = Counter("mississippi")
print(counts) # Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})
print(counts.most_common(2)) # [('i', 4), ('s', 4)]
word_counts = Counter(["cat", "dog", "cat", "bird", "dog", "cat"])
print(word_counts["cat"]) # 3
print(word_counts["fish"]) # 0 — missing keys return 0, NOT KeyError!
# Counter arithmetic
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)
print(c1 + c2) # Counter({'a': 4, 'b': 3})
print(c1 - c2) # Counter({'a': 2}) — subtraction drops non-positive counts
The defaultdict factory-function gotcha
d = defaultdict(list)
print(d["missing"]) # [] — accessing a missing key CREATES it as a side effect!
print(list(d.keys())) # ['missing'] — now actually in the dict, from a mere READ
This surprises many developers: simply reading d[key] on a defaultdict inserts key into the dict if it was absent — a read has a mutating side effect, unlike a normal dict where d[key] on a missing key raises KeyError and touches nothing. This matters if you later check len(d) or iterate d expecting only keys you explicitly set.
Dictionary and Set Comprehensions
# Dict comprehension
squares = {x: x ** 2 for x in range(6)}
print(squares) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Invert a dict (only safe if values are unique AND hashable)
original = {"a": 1, "b": 2, "c": 3}
inverted = {v: k for k, v in original.items()}
print(inverted) # {1: 'a', 2: 'b', 3: 'c'}
# Filter while building
prices = {"apple": 1.5, "bread": 3.0, "milk": 2.5}
affordable = {item: price for item, price in prices.items() if price < 3.0}
print(affordable) # {'apple': 1.5, 'milk': 2.5}
# Set comprehension
unique_lengths = {len(word) for word in ["cat", "dog", "bird", "ox"]}
print(unique_lengths) # {3, 4, 2} — duplicates (cat/dog both len 3) collapse automatically
Sets — Unordered Collections of Unique, Hashable Elements
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # {1, 2, 3, 4, 5, 6} — union
print(a & b) # {3, 4} — intersection
print(a - b) # {1, 2} — difference (in a, not in b)
print(a ^ b) # {1, 2, 5, 6} — symmetric difference
print(a.issubset({1, 2, 3, 4, 5})) # True
print({1, 2}.issubset(a)) # True
print(a.isdisjoint({100, 200})) # True — no overlap
a.add(5)
a.discard(100) # no error even if 100 is absent (unlike .remove())
# a.remove(100) # KeyError: 100 — remove() raises if the element is absent
print(a)
Sets deduplicate automatically — a common idiom
emails = ["a@x.com", "b@x.com", "a@x.com", "c@x.com", "b@x.com"]
unique_emails = list(set(emails)) # order NOT guaranteed to match input!
print(sorted(unique_emails)) # sort explicitly if order matters
# To deduplicate WHILE preserving original order, use dict.fromkeys (3.7+)
# since dicts preserve insertion order but sets do not:
ordered_unique = list(dict.fromkeys(emails))
print(ordered_unique) # ['a@x.com', 'b@x.com', 'c@x.com'] — first-seen order preserved
Hashability — What Can Be a Dict Key or Set Member
Only hashable objects can be dict keys or set elements. An object is hashable if it defines __hash__ (and, by convention, __eq__ consistently) and — critically — is immutable in the properties used for hashing.
d = {}
d[(1, 2)] = "point" # tuples of hashable items: OK
d["string"] = "ok" # strings: OK
d[42] = "ok" # ints: OK
d[frozenset([1, 2])] = "ok" # frozenset: OK (the immutable set variant)
# d[[1, 2]] = "fails" # TypeError: unhashable type: 'list'
# d[{1, 2}] = "fails" # TypeError: unhashable type: 'set'
# d[{"a": 1}] = "fails" # TypeError: unhashable type: 'dict'
list, dict, and set are all unhashable because they're mutable — if you could hash a list and then mutate it, its hash would need to change, silently breaking every hash-table invariant (you'd never be able to find it again by its now-stale hash bucket). This is a deliberate, load-bearing design constraint, not an arbitrary limitation.
Complexity Cheat Sheet
| Operation | dict | set | Complexity |
|---|---|---|---|
Lookup / membership in | d[k], k in d | x in s | O(1) average |
| Insert | d[k] = v | s.add(x) | O(1) average |
| Delete | del d[k] | s.remove(x) | O(1) average |
| Iteration | for k in d | for x in s | O(n) |
This O(1) average lookup is why x in some_set or x in some_dict is dramatically faster than x in some_list (O(n) linear scan) for large collections — one of the most impactful, easy performance wins in everyday Python code.
# SLOW for large data: O(n) per lookup, O(n * m) total
allowed_ids_list = list(range(100_000))
def is_allowed_slow(user_id):
return user_id in allowed_ids_list # linear scan every call
# FAST: O(1) per lookup, O(m) total
allowed_ids_set = set(range(100_000))
def is_allowed_fast(user_id):
return user_id in allowed_ids_set # hash lookup every call
💡 Tips & Tricks
dict.fromkeys(iterable)deduplicates while preserving first-seen order — the idiomatic replacement forlist(set(x))whenever order matters (which is often, e.g., preserving user-input ordering in logs or UI lists).Counter.most_common(n)replaces manual sort-by-count boilerplate — for "top N most frequent items" tasks, this one call replaces building a list of tuples and sorting it yourself.ChainMaplayers multiple dicts without copying —collections.ChainMap(overrides, defaults)looks up keys inoverridesfirst, thendefaults, without merging them into a new object — ideal for CLI-args-over-config-file-over-defaults precedence chains.dict | other(3.9+) is the modern, allocation-clear way to merge two dicts — clearer than{**a, **b}for simple two-dict merges, though{**a, **b, "extra": 1}remains necessary when adding ad hoc keys inline.frozensetfor hashable, immutable sets — when you need a set as a dict key or set member (e.g., memoizing a function that takes an unordered collection of tags), convert withfrozenset(my_set).
⚠️ Edge Cases & Gotchas
.keys()/.values()/.items()are live views, not snapshots — they reflect the dict's current state even after later mutation, and raiseRuntimeError: dictionary changed size during iterationif you mutate the dict's size while iterating one of these views directly.- Merely reading a missing key on a
defaultdictinserts it —d[key]on adefaultdictis not side-effect-free like it is on a regular dict; usekey in dor.get(key)(which does NOT trigger the factory) when you want to check without inserting. - Dict/set key uniqueness depends on
__eq__and__hash__agreeing —1 == 1.0 == Trueare all mutually equal in Python and all hash to the same value, so{1, 1.0, True}collapses to a set with a single element (whichever was inserted first is retained):{1, 1.0, True} == {1}. This surprises people who expect type to matter for set/dict-key equality. - Mutating an object after using it as a dict key (if it happens to be a mutable-adjacent custom class) corrupts the hash table — if you implement a custom
__hash__based on mutable fields and then mutate those fields, the object becomes unfindable in its own dict/set (it hashes to a different bucket than where it was originally placed) — this is why hashable custom classes should base__hash__only on fields that never change after construction (chapter 14 covers implementing__hash__correctly). - Dict/set iteration order for sets is insertion-order-like in small cases but is NOT guaranteed by the language at all — unlike dicts (where insertion-order preservation is a documented guarantee since 3.7), sets make no ordering guarantee whatsoever; two sets with identical elements inserted in a different order may iterate differently, and this can change between Python versions or even between runs with hash randomization (
PYTHONHASHSEED) enabled for strings.
🧠 Spot the Bug
A caching layer uses a defaultdict to track per-user request counts and logs "new users." What's wrong?
from collections import defaultdict
request_counts = defaultdict(int)
def log_request(user_id):
is_new_user = user_id not in request_counts
count = request_counts[user_id] # bumped below
request_counts[user_id] += 1
if is_new_user:
print(f"New user seen: {user_id}")
def get_active_user_count():
return len(request_counts)
log_request("alice")
print(get_active_user_count())
# Somewhere else in the codebase, an unrelated debug check:
if "bob" in request_counts:
print("bob has made requests")
print(request_counts["bob"]) # a "harmless" debug print
print(get_active_user_count())
Answer
After the debug print, get_active_user_count() returns 2, not 1 — even though "bob" never made a real request. print(request_counts["bob"]) reads a missing key on a defaultdict(int), which silently inserts "bob": 0 as a side effect of the read. The subsequent get_active_user_count() (which just does len(request_counts)) now counts bob as an "active user" purely because someone printed his count for debugging.
The fix: use request_counts.get(user_id, 0) for read-only inspection (never triggers the factory), and reserve request_counts[user_id] for code paths that genuinely intend to create-or-update an entry.
The lesson: defaultdict trades KeyError safety for a subtler hazard — reads are not side-effect-free, so in-checks and len() after speculative/debug reads can silently include entries nobody meant to create.
Key Takeaways
- Dicts preserve insertion order (guaranteed since 3.7);
.keys()/.values()/.items()return live views, not static lists. .get()and.setdefault()avoidKeyErrorfor reads and conditional inserts respectively; prefer them over manualif key in dboilerplate.defaultdictandCounterfromcollectionseliminate common manual-initialization boilerplate — but remember that reading a missing key on adefaultdictinserts it.- Only immutable-by-contract, hashable objects (numbers, strings, tuples-of-hashables, frozensets) can be dict keys or set members — this is a deliberate consequence of how hash tables work, not an arbitrary restriction.
x in some_set/x in some_dictis O(1) average vs. O(n) forx in some_list— converting a large membership-checked list to a set is one of the highest-leverage, lowest-effort performance fixes in everyday Python.