python/volume-4/chapter-54ch 54 / 64 · 9 min

54The hash map — a value by its key, instantly

In Chapter 53 we gave the list two fast ends and called it a deque. But every structure so far, the deque included, still reaches a thing by its position: you either know the index or you walk to it. Here we break that pattern. A hash map — Python spells it dict — answers "what's the value for this key?" without touching any other entry. Not by walking, and not by binary search, but by computing the address straight from the key and jumping there in a single step. Here's the plan. We'll watch a key turn itself into a bucket number, see what happens when two keys want the same slot, learn why the table is kept deliberately two-thirds empty, weigh why it's the heaviest structure per item you'll meet, and find out why a key has to be frozen solid. All the way through we keep asking the one question the trick hangs on: how can you find one entry among millions without ever searching for it? By the end you'll read d[key] and see the hash, the bucket, and the single jump — and you'll know on sight when a problem is really a hash-map problem.

The address is hidden inside the key When two keys want one bucket Two-thirds full, then grow The heaviest structure per item
scroll to begin

01The address is hidden inside the key

Let's start with the limit every structure so far has quietly shared. A list lands on a[i] with one multiply-and-add, because its cells are contiguous in memory (Volume 1, where we first laid an array down). But that only works when you already know the index. So watch what happens the moment your key isn't a number — a song title, a username, an IP address, a label rather than a position. Searching a list for it is an O(n) walk. Keeping it sorted and binary-searching drops that to O(log n) (Volume 3). The hash map does the thing that sounds impossible: it reaches the value in O(1) average. That's one jump, whether two entries sit inside or two billion.

The trick is a function called hash. Feed it any hashable object and it hands back a fixed-size integer — a scramble of the key's contents. Take that integer mod the table size and you have an index into a plain array of slots called buckets. So the key computes its own address: bucket = hash(key) % N. To store, you scramble the key to a bucket and drop the value there. To look up, you scramble the same key to the same bucket and read the value back. Both touch exactly one slot. That's the whole idea, and everything else in this chapter is us defending it.

key "Levels" hash("Levels") = 7641909519374694469 a big scramble (randomized per run) % 8 → bucket 5 low bits pick the slot the table: a deliberately sparse array of 8 buckets 0empty 1empty 2 hash·key●·val● 3empty 4empty 5 hash·key●·val● 6empty 7empty the heap — key and value objects live here, the bucket only holds references to them: str "Levels" (the key) int 84 (the value) Store: hash(key) % N → drop the reference in that bucket. Lookup: hash(key) % N → read it back. One slot touched, either way. Because the array is contiguous, "go to bucket 5" is the same one address computation that makes a[i] O(1) — but here the index came from the key, not from you. The table stays mostly empty on purpose (next section).
Fig — A hash map is a sparse array of buckets. The key runs through hash then % N to name its own bucket; the occupied slot stores the hash and two references — one to the key object, one to the value object, both on the heap (Volume 1). Lookup repeats the same computation and lands in one jump: that is where O(1) comes from.

Here is the derivation the O(1) hangs on. Computing hash(key) depends on the key's own size, not on how many entries the table holds. The % N is one integer op. The array is contiguous, so "jump to bucket b" is one base + b × slot address computation — the exact primitive from Volume 1's list. Add those up and the work to find any entry is independent of n. That is the definition of O(1). The list had to walk n slots, and the sorted array bisected to log n. The hash map skips the search entirely, because the key already knows where it lives.

arr.pypython
>>> hash(42)          # small ints hash to themselves
42
>>> hash(42) % 8      # ... so the bucket is just the low bits
2
>>> d = {"Levels": 84, "Strobe": 631}
>>> d["Levels"]       # hash("Levels") % N -> one jump -> 84
84

I ran it. hash(42) is 42 (small integers hash to themselves), so hash(42) % 8 is 2 — bucket 2, computed straight from the key. Notice what d["Levels"] doesn't do: it never looks at the "Strobe" entry at all. It hashes "Levels", takes the mod, jumps, and reads. Two entries or two billion, the lookup does the same amount of work.

InteractiveTwo entries or two billion — the list scans, the dict jumps
to find ONE key by its label… 6 keys the list checks to find one — the dict checks 1 list scan, O(n) 6 dict jump, O(1) 1 bars on a log scale If each check took one second: the list scans for 6 seconds — the dict, 1 second. The dict does the same single jump — and the gap only widens as n grows.
n = 10 entries
Drag from a handful of entries to a billion. The list has to walk on average half of them to find a key — its bar climbs a log axis toward hundreds of millions of checks. The dict hashes the key straight to its bucket: one slot, always. Its bar never moves. That flat purple bar is the whole reason the structure exists.

One jump — as long as no two keys ever want the same bucket. But with 8 buckets and a 9th key, two must collide. What happens then? →

02When two keys want one bucket

Collisions are not a bug; they are arithmetic. You are squeezing a giant space of possible keys through % N into N slots. So the moment you have more than N keys, two of them share a bucket by the pigeonhole principle — and long before that, birthday-style coincidences make near-collisions common. A hash map is really two ideas joined together: a function that scatters keys evenly, and a plan for when the scatter lands two keys on the same slot.

There are two classic plans. Chaining: each bucket holds a little linked list, and colliding keys hang off the same bucket. Open addressing: keep everything in the one flat array, and if your bucket is taken, probe — try another slot by a fixed rule until you find a free one. CPython's dict uses open addressing, because a flat array streams through cache far better than chasing per-bucket linked-list nodes across the heap (Volume 3, where pointer-chasing stalls while contiguous memory runs fast). The figure below shows the simplest probe, which is to walk to the next slot. But see the deeper cut: real CPython uses a perturbed sequence so collisions don't clump.

keys 12 and 20 both hash to bucket 4 (12 % 8 = 4, 20 % 8 = 4) 0 1 2 3 412 520 6 7 12 lands in bucket 4 20 finds 4 taken → probe → bucket 5 Lookup replays the same probe: hash 20 → bucket 4 → occupied by 12, not 20 → step to 5 → found. A cluster of collisions turns O(1) into a short walk.
Fig — Open addressing. When two keys claim one bucket, the latecomer probes forward to the next free slot; lookup replays the identical probe path. Collisions cost a few extra steps — cheap while the table is sparse, ruinous if it fills up.

Build the intuition by driving it. In the widget, pick a key and watch it compute hash % 8 and light up its home bucket. Insert it, and watch a collision probe forward to the next free slot.

InteractiveTurn a key into a bucket — and watch a collision probe
pick a key, then press Insert
12
For integers, hash(n) == n, so the home bucket is just n % 8. Amber = the home bucket the key hashes to; red arrows = the probe when it's already taken; purple = where the key finally lands. (Simplified to linear probing — see the deeper cut.)
The deeper cut
Linear probing (try the next slot) is the friendly version — and it has a flaw: collisions clump, because two different home buckets can grow into the same run of occupied slots, and clumps make probes longer. CPython avoids this with a perturbed probe: the next slot to try is j = (5·j + 1 + perturb) & mask, with perturb seeded from the full hash and shifted right 5 bits each step. Early probes scatter across the table (driven by the high bits of the hash), so unrelated keys don't pile into one run; later probes fall back to a simple scan that's guaranteed to visit every slot. Also, CPython indexes with hash & (N−1) rather than hash % N — identical when N is a power of two (which it always is), just faster, and the reason the table size is always 8, 16, 32, 64…

Probing stays cheap only while there's plenty of free space to probe into. Let the table fill up and every insert turns into a long hunt. So the map watches how full it is — and acts. →

03Two-thirds full, then grow

The number that governs a hash map's health is the load factor: entries divided by buckets. Empty is 0, and half full is 0.5. As it climbs toward 1.0, free slots vanish, probe chains lengthen, and the O(1) decays into an O(n) scan. So the map never lets it get close. CPython resizes the moment the table would cross two-thirds full. It grows the bucket array (doubling it) and rehashes every existing key into the bigger table, because each key's bucket is hash % N and N just changed.

You can watch it happen with sys.getsizeof. I inserted integers one at a time and printed only when the table's byte size jumped:

load.pypython
import sys
d = {}
prev = sys.getsizeof(d)
for i in range(1, 130):
    d[i] = i
    s = sys.getsizeof(d)
    if s != prev:                 # only print the resize moments
        print(i, prev, "->", s)
        prev = s
# 1   64 -> 224     (first key: an 8-bucket table appears)
# 6   224 -> 352    (6th key crosses 2/3 of 8  -> grow to 16)
# 11  352 -> 632    (11th crosses 2/3 of 16    -> grow to 32)
# 22  632 -> 1168   (22nd -> 64)   43  1168 -> 2264  (-> 128)   86  2264 -> 4688  (-> 256)

Read the resize points: 6, 11, 22, 43, 86. Those are exactly two-thirds of 8, 16, 32, 64, 128 — the table doubles every time it crosses the line. An empty dict is a bare 64-byte header with no bucket array at all. The first key creates an 8-slot table, jumping the size to 224 bytes. From there, each resize copies every key into a fresh, larger, mostly-empty array. That copy is O(n), but it happens rarely and the table doubles, so the cost amortizes to O(1) per insert (the same amortized bookkeeping as the list's overallocation, back in Volume 3). You pay a big bill occasionally to keep every other insert instant.

InteractiveFill it up — watch the table resize and rehash at 2/3
2/3 line → resize empty table — 8 buckets
size 8 · used 0 · 0%
The purple bar is the load factor. Cross the amber 2/3 line and the table doubles and rehashes — every key jumps to a new bucket in the bigger array, and the load drops back to a safe ~1/3.
Give it the size up front when you can
If you know you're about to load a million keys, building the dict in one pass still triggers a chain of resizes as it grows through 8, 16, 32 … up to the final size — each one an O(n) rehash of everything so far. Constructing from a comprehension or dict(pairs) lets CPython presize the table once and skip the intermediate copies. Same O(1) amortized cost, but you avoid replaying the whole rehash ladder.

Doubling to stay two-thirds empty is why lookups fly — and also why a hash map is the heaviest structure you'll carry. Let's weigh it. →

04The heaviest structure per item

Every deal with memory has a price, and the hash map's is space. Look back at the layout. A big fraction of the buckets are deliberately empty — that's the load factor — and each occupied bucket doesn't just hold a value. It holds the hash, and a reference to the key, and a reference to the value. A list stores one 8-byte reference per element and nothing else. The dict stores far more, and keeps spare slots on top. Here's the same data both ways, measured:

cost.pypython
import sys
keys = range(1000)
d = {k: k for k in keys}
L = list(keys)
print(sys.getsizeof(d))   # 36952  -> ~37 bytes per slot
print(sys.getsizeof(L))   # 8056   -> ~8  bytes per slot

big = {i: i for i in range(1_000_000)}
print(sys.getsizeof(big)) # 41943128  -> ~41.9 bytes per entry (~41.9 MB)

The list of 1000 is 8,056 bytes — about 8 per element, one reference apiece. The dict is 36,952 bytes, roughly 37 per slot. That's four to five times heavier, and it's just the table, before counting the key and value objects it points to. Scale up and it holds: a million-entry dict weighs ~41.9 MB of table alone (~41.9 bytes/entry). That is the space-for-speed bargain stated in bytes. You buy the single-jump lookup by keeping the array sparse and paying for two references and a cached hash per entry.

list — dense, 1 reference/slot (~8 B) ref ref ref ref ref ref no gaps, no per-item overhead beyond the reference hash map — sparse, 3 fields/slot (~37 B) empty hashkey● val● empty hashkey● val● empty empty ~1/3 full by design · hash + key ref + value ref per entry Measured: 1000 items — list 8,056 B vs dict 36,952 B. The dict is ~4.6× heavier per item — the cost of the single jump. If you only need order and position, a list is far lighter. Reach for a dict when you need lookup by key — then the extra bytes buy something a list can't.
Fig — Same data, two layouts. The list is a dense array of references; the hash map is a sparse array where each live slot carries a hash and two references, with a third of the slots empty on purpose. That sparsity and per-entry overhead is why it's the heaviest structure per item — and why the lookup is instant.
The deeper cut — CPython's compact dict
Since Python 3.6, a dict is cleverer than the "array of fat buckets" picture. It splits into two arrays: a sparse index array (the hash table proper — small integers, one byte each while the dict is tiny, pointing into…) and a dense entries array that stores the (hash, key-ref, value-ref) triples in insertion order. The sparse part stays cheap because it holds only indices, not full 24-byte entries; the dense part wastes nothing and — as a free side effect — makes dict remember the order you inserted keys. That's not a promise the language always made; it's a consequence of this layout, and it's why list(d) comes back in insertion order. I confirmed it: inserting Strobe, Levels, Titanium, Encore and reading the dict back yields exactly that order.

Sparse, reference-heavy, self-resizing — and it all rests on one quiet assumption about the key: that its hash never changes. Break that and the value vanishes. →

05Why a key must be frozen

A hash map finds a key by recomputing hash(key) and going to that bucket. That only works if the key hashes to the same bucket every time. So the key must be hashable, which in practice means immutable: its contents, and therefore its hash, can never change while it's in the map. Strings, numbers, and tuples of immutables qualify. Lists and dicts do not, and Python refuses them outright:

hashable.pypython
>>> {[1, 2]: "x"}
TypeError: unhashable type: 'list'
>>> {(1, 2): "point"}[(1, 2)]      # a tuple is frozen -> fine
'point'

Why the hard ban? Picture what a mutable key would do. Suppose the map let you use a mutable object whose hash tracked its contents. You store value under it, landing in bucket 4. Then you mutate the key, and now it hashes to bucket 7. Ask the map for it: lookup goes to bucket 7, finds nothing, and reports the key missing — while the value sits orphaned in bucket 4, unreachable. I built exactly that trap:

hashable.pypython
class Bad:
    def __init__(self, v): self.v = v
    def __hash__(self): return hash(self.v)   # hash tracks a mutable field
    def __eq__(self, o):  return self.v == o.v

k = Bad(1)
store = {k: "found me"}
print(store.get(k))        # 'found me'   -> hashes to its bucket, found
k.v = 999                  # mutate the key: its hash just changed
print(store.get(k))        # None         -> now hashes elsewhere, lost
print("found me" in store.values())   # True -> still physically in the table

The output was 'found me', then None, then True. The value never left the table; the key just stopped pointing at the right bucket. That is why immutability isn't a style rule here, it's a correctness requirement. Freezing the key is the price of computing its address. (This is the Volume 1 callback made concrete: hashability is exactly the contract a key signs.)

store[k] = "found me" → hash(k)=1, bucket 4 0 1 2 3 4"found me" 5 6 7 k.v = 999 → hash(k) is now 7 → store.get(k) looks in bucket 7 orphaned — value still physically here… …but lookup goes here → empty → None The map didn't lose the value; it lost the address. When the key's hash moves, the entry becomes unreachable — the value lingers in bucket 4 forever, and every lookup reports the key missing.
Fig — Why a key must be frozen. Mutate a key that's already stored and its hash — hence its bucket — changes. Lookup now visits the wrong, empty bucket and returns None, while the value sits orphaned where it was first placed. Immutability is what guarantees a key always hashes home.
The bug Python won't raise for you
Using an unhashable key is a clean, loud TypeError — you find out instantly. Mutating a hashable object after it's already a key is the opposite: silent. No exception, no warning — the entry just quietly becomes unreachable, and you get a baffling "the key I definitely inserted isn't there." The classic form is putting a mutable object in a set and then changing it, or using a custom class whose __hash__/__eq__ read fields you later reassign. Rule of thumb: a value used as a key or set member must be treated as frozen from that moment on.
↺ The thing people get backwards
People memorize "dict lookup is O(1)" as a law of nature. It isn't — it's an average that depends on the hash scattering keys evenly. Feed a map a pile of keys that all hash to the same bucket and every insert probes past all the previous ones: the lookup degrades to a linear O(n) scan, the worst case textbooks quietly footnote. This isn't hypothetical. Attackers once crashed web servers with hash flooding — POST bodies full of colliding keys that turned a dict into a linked list and pinned the CPU at O(n²). The fix ships in Python today: hash() of a string is randomized per process with a secret seed, so an attacker can't predict which keys collide. The O(1) is real, but it's a statistical guarantee the runtime actively defends — not a promise the arithmetic makes for free.
hashable.pypython
$ python -c "print(hash('secret'))"
-7922894392216367691
$ python -c "print(hash('secret'))"
7804674963121063573        # different seed each process -> different hash
$ python -c "print(hash(42))"
42                          # ints aren't randomized: hash(n) stays n

I ran hash('secret') in three fresh interpreters and got three unrelated numbers, while hash(42) came back 42 every time. String hashes are salted per process (that's PYTHONHASHSEED at work). Integer hashes aren't, because they're not the flooding target. Same key, same run gives the same bucket. A different run may give a different bucket. That's also why you should never persist a Python hash to disk and expect it to match next launch.

A Python hash is not a checksum
Because hash() of strings (and bytes, and anything built from them) is salted with a fresh per-process seed, it is not stable across runs — never write one to a file, a database, or a cache key expecting it to match later, and never use it as a content fingerprint. For a stable digest you want hashlib (sha256 and friends), which are deterministic by design. hash() exists to place objects in this process's table, nothing more.

Myth

"A dict is O(1), always and unconditionally — that's why you use it."

Reality

O(1) is the average under a good hash and a load factor kept below 2/3. Adversarial or pathological keys make it O(n). Python defends the average with hash randomization and resizing — the guarantee is engineered, not automatic.

Frozen keys, defended averages, a table two-thirds empty — add it up and you have the workhorse of modern computing. Where does it actually run? Almost everywhere, including inside Python itself. →

06Where you meet this — everywhere, including inside Python

The access pattern the hash map owns is "reach a thing by its label", and once you see it, you see it constantly. Every dict you write, obviously. But also: database indexes (a hash index turns a WHERE-clause value straight into a row location, with no table scan); Redis and memcached, whole databases that are essentially one giant hash map living in RAM, serving billions of lookups a second; deduplication (drop everything into a set, and collisions reveal the duplicates); a compiler's symbol table, mapping every variable name to its type and address as it parses your code; and memoization/caching@lru_cache is a dict keyed by your function's arguments.

You used one to read this sentence
The deepest place a hash map hides is inside Python itself. Every time you access an attribute — obj.name, module.function, a global variable — the interpreter looks it up in a dict (the object's or module's __dict__). Running any Python program is a torrent of hash-map lookups. The structure isn't something you occasionally reach for; it's the substrate the language runs on. You didn't just learn a container — you learned how Python finds everything.

A set is the same machinery with the values thrown away: a hash table that stores only keys, answering "is x in here?" in O(1). I measured a membership test on a million-element set at about 38 nanoseconds. The list's worst-case scan of the same size I clocked at roughly 6.4 milliseconds, over a hundred-thousand times slower. When your question is "have I seen this before?" or "is this in the allowed set?", that gap is the whole reason sets exist.

dict — bucket stores three fields hash key ● val ● set — same table, value discarded hash key ● — none — Membership — x in s: set: hash → bucket → present? O(1) — measured ~38 ns @ 1M list: scan every element O(n) — measured ~6.4 ms @ 1M
Fig — A set is a hash map with the value column removed — the buckets still scatter keys by hash, so "is x in here?" is a single jump. Measured on a million elements, set membership (~38 ns) beat a list scan (~6.4 ms) by over a hundred-thousandfold. Same idea, one job.
The one-line tell for reaching for a hash map
Being good at data structures is mostly matching an access pattern to a structure, and the hash map's tells are the most common of all: you need to look something up by a key; you need to test membership fast; you need to count or group things (a dict from item to tally); or you need to dedupe. See any of those and reach for a dict or set — accepting that you're trading roughly 4–5× the memory of a list for a lookup that doesn't care how big the data gets. If instead you need order, position, or the smallest element, this isn't your structure.
Wait —
if a hash map finds anything by key in one jump, why does any database still bother keeping data sorted in a tree? What can "sorted" do that "instant" can't?

Because a hash map scatters keys to random buckets, it destroys one thing utterly: order. Ask it for "the smallest key," or "every key between 10 and 20," and it has no answer but to scan everything. The structures ahead keep order on purpose. But first, the next chapter leaves the world of one-dimensional rows entirely — it arranges data in a grid, rows and columns, and asks a question that decides how fast every image filter and neural network runs: when you store a 2-D table in 1-D memory, which way do you lay it down? →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 54, in working code

A hash map hides a memory address inside the key itself. So let's build one from a plain list of buckets, watch two keys fight over a slot, grow the table when it fills, and see why the whole trick collapses the moment a key can change shape.

The one-jump lookup
hash(key) % N names the bucket, and the array jumps straight there. Two entries or two billion, the lookup does the same work.
When two keys want one bucket
Squeeze a huge key space into a few buckets and collisions are arithmetic, not bugs. Open addressing probes forward to the next free slot — and lookup replays the identical path.
Two-thirds full, then grow
The load factor — entries over buckets — governs a map's health. Cross 2/3 and Python doubles the table and rehashes every key, dropping the load back to a safe third.
Why a key must be frozen
A hash map finds a key by recomputing hash(key). That only works if the hash never moves — so keys must be immutable, and equal keys must hash the same.
A set is a dict without values
Drop the value column and the same buckets scatter keys by hash — so 'is x in here?' is a single jump. Then the real payoff: counting and deduping in one pass instead of a scan.
end of chapter 54 · six sections
iolinked.com
Written by Ajai Raj