◈ python mapVol 4 · Ch 54/62
Volume 4 Python, from the metal up · chapter 54

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, deque included, still reaches a thing by its position. You either know the index or you walk to it. Here we break that spell. A hash map — Python spells it dict — answers "what's the value for this key?" without touching any other entry. It doesn't walk, and it doesn't binary-search. It works 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. We'll see what happens when two keys want the same slot. We'll learn why the table is kept deliberately two-thirds empty, why it's the heaviest structure per item you'll meet, and why a key has to be frozen solid. The whole 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.

★ YOU ALREADY RUN THIS · the-pigeonhole-wallyou have never read two hundred labels — your own name told you where to look
Parcel day. You come in from work, and the wall in the entrance hall carries twenty-six pigeonholes, one per letter, and yours is R. Two hundred people live in this building. You do not read two hundred labels — your own name told you which box to open, and the four envelopes inside it are the only four you will ever touch. You flick through them: not you, not you, yours. On a quiet week there is a single envelope in R and you are gone in a second. In December there are thirty, and the flick becomes a hunt. Notice exactly what changed and what did not: the wall never got slower. Your box got crowded.
the first letter of your name names the boxbucket = hash(key) % N · the key computes its own address
you never read the other twenty-five boxesthe lookup touches one slot, not n — that, and only that, is the O(1)
four envelopes share R, so you check the namesa collision — landing in the slot proves nothing; the map compares the actual key
December: thirty in R, until the porter mounts a second wall and every name moves boxthe load factor climbing, then resize and rehash — change N and every address changes with it
the coat-check ticket back in Chapter 37the ticket decided where the coat went; the key decides where the value lives — same arithmetic, now holding your data
pin it: you have never once searched a wall of pigeonholes — your own name told you which one to open, and that is the entire difference between finding and searching.
iolinked · chapter 54 — the checkpoints6 steps
$ sections covered in The hash map — a value by its key, instantly
01The address is hidden inside the key
02When two keys want one bucket
03Two-thirds full, then grow
04The heaviest structure per item
05Why a key must be frozen
06Where you meet this — everywhere, including inside Python

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 — 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. Now take that integer mod the table size. That gives you an index into a plain array of slots called buckets. So the key computes its own address: bucket = hash(key) % N. Store means "scramble to a bucket, drop the value there." Lookup means "scramble to the same bucket, read the value back." Both touch exactly one slot. That's the whole idea. Everything else in this chapter is us defending it.

You might reasonably ask why we bother hashing at all. Why not just use the key itself as the index? There are two reasons. First, most keys aren't numbers. A username or a song title has no natural row number, so there's nothing to index an array with until you turn it into one. Second, even when the key is a number, it can be astronomically large or sparse. A user ID like 900431772 would demand an array with nearly a billion slots to use it directly. Hashing solves both problems at once. It turns any key into an integer, and % N folds that integer down to fit the table you actually have.

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 them up: the work to find any entry is independent of n. That is the definition of O(1). The list had to walk n slots; 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", mods, jumps, reads. Two entries or two billion, the lookup does the same amount of work.

That 42 is a special case, and I'm flagging it so it doesn't quietly mislead you. Small integers hash to themselves, but a string gets genuinely scrambled. Feed hash a title like "Levels" and it returns a big, unpredictable integer with no visible relationship to the letters. The mod is what tames it. Say the scramble came out as 5381927. Then 5381927 % 8 is 7, because 8 divides evenly into 5,381,920 and leaves 7 left over. A wild integer, folded down into one of eight real slots. That fold is how any key, however large its hash, still names a bucket that exists.

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 only N slots. And the moment you hold more than N keys, two of them must share a bucket by the pigeonhole principle. Long before you even reach that point, birthday-style coincidences already make near-collisions common. So a hash map is really two ideas welded together, and the first is a function that scatters keys evenly across the whole table. The second is a plan for when the scatter lands two keys on the same slot.

Let's make that "birthday-style" claim a real number instead of a hand-wave. Take the smallest live table, 8 buckets, and drop keys into it one at a time. The first key is free, the second dodges it with probability 7/8, the third with 6/8, the fourth with 5/8. Multiply those surviving fractions together: 8/8 × 7/8 × 6/8 × 5/8 = 1680/4096, which is about 0.41. So the chance that four keys land with no collision at all is only 41%. That means a collision is already more likely than not — about 59% — with the table barely half full. That is exactly why the probe path in the next section is not some rare edge case. It is the normal weather of a hash map, and the whole design exists to make it cheap.

There are two classic plans. The first is chaining: each bucket holds a little linked list, and colliding keys hang off the same bucket. The second is open addressing: keep everything in the one flat array, and if your bucket is taken, probe for another. You try a different slot by a fixed rule until you find a free one. CPython's dict uses open addressing. 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 flies). The figure below shows the simplest probe: walk to the next slot. But the real story is deeper. Real CPython uses a perturbed sequence so collisions don't clump.

Here's the subtlety that makes open addressing actually work. When two keys collide and the second one probes forward, a later lookup has to tell them apart. So landing on a bucket is never proof by itself. The map compares the key you asked for against the key stored in that slot. If they don't match, it keeps probing along the exact same path the insert took, until it finds your key or hits an empty slot. This is why each slot caches the hash. Comparing two 8-byte hashes first is cheap, and only when those match does the map do the slower full equality check on the keys. A jump, a hash compare, and maybe a key compare — that's the real anatomy of one lookup.

Let's trace one real lookup so the anatomy stops being abstract. Say "Levels" already lives in bucket 7, and you now insert "Faded", which happens to hash to bucket 7 as well. That slot is taken, so "Faded" probes forward one step to (7 + 1) % 8, which is bucket 0, and settles there. Later you ask the map for "Faded". It jumps to the home bucket 7 and compares the cached hash — that matches, but the full key stored there reads "Levels", so it is not your key. So the map keeps probing, steps to bucket 0, matches the hash again, and this time the key check agrees and hands back your value. Two slots touched, two cheap hash compares, one full key compare. That is a collision costing three extra instructions, not a search.

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 its home bucket. Then insert it and watch a collision probe forward to the next free slot.

⚠ MOST BEGINNERS THINK…two keys that look different must be two different keys
We have now watched it twice — once in the trace, once in the figure: landing in a bucket proves nothing, so the map compares the key you asked for against the key stored in that slot. That comparison is where a very reasonable belief takes root, and it is worth putting under a light now rather than leaving it to section 05. If a map keeps a key and hands it back on request, then a key must be a name — a thing you write down once and write down again later — so anything I can type is a key, and two things I typed differently are two separate keys. Every observation propping that up is true. 1, 1.0 and True print differently, carry three different types, and arrive from three different corners of the language. Nobody gets here carelessly. It is exactly what “a key can be anything hashable” sounds like it means.
TYPE THIS — 10 SECONDS
from collections import Counter

flags = [True, 1, 1.0, False, 0]
print("Counter(flags) ->", Counter(flags), "| len:", len(Counter(flags)))
print("{True: 'yes', 1: 'no'} ->", {True: 'yes', 1: 'no'}, "| len({0, False, 0.0}):", len({0, False, 0.0}))
d = {1: 'a'}
d[1.0] = 'b'
d[True] = 'c'
print("d after d[1]=a, d[1.0]=b, d[True]=c ->", d, "| key type:", type(list(d)[0]).__name__)
print("isinstance(True, int) ->", isinstance(True, int), "| 1 == 1.0 == True ->", 1 == 1.0 == True,
      "| hashes:", hash(1), hash(1.0), hash(True))
nan = float('nan')
print("hash(nan) == hash(float('nan')) ->", hash(nan) == hash(float('nan')), "| nan == nan ->", nan == nan)
print("{nan: 1}[nan] ->", {nan: 1}[nan], "| float('nan') in {nan: 1} ->", float('nan') in {nan: 1})
Counter(flags) -> Counter({True: 3, False: 2}) | len: 2
{True: 'yes', 1: 'no'} -> {True: 'no'} | len({0, False, 0.0}): 1
d after d[1]=a, d[1.0]=b, d[True]=c -> {1: 'c'} | key type: int
isinstance(True, int) -> True | 1 == 1.0 == True -> True | hashes: 1 1 1
hash(nan) == hash(float('nan')) -> False | nan == nan -> False
{nan: 1}[nan] -> 1 | float('nan') in {nan: 1} -> False
Five items went into that tally and two categories came back, both of them boolean, describing data that was never boolean. Nothing miscounted. That is the actual rule arriving in the worst possible costume: a dict is keyed by value, not by spelling — two objects that hash the same and compare equal are one key. The map has no way to tell such objects apart, because the only questions it ever asks a slot are the ones we just watched it ask. Line four prints the mechanism rather than the surprise — 1 == 1.0 == True is True and all three hash to 1 — so all three compute the same address and pass the comparison waiting there. Line three shows what that costs you: three writes went in, one entry came out. Section 05 will state this as a law from the writer's side (equal keys must hash equal); this is the same law read from the reader's side, and it does not merely permit the collapse, it requires it. If 1.0 hashed anywhere else, storing under 1 and reading with 1.0 would miss a key the map is holding.

Now look at which one survived, because that is the half that bites. Line two: {True: 'yes', 1: 'no'} prints {True: 'no'}. The label is True and the value is 'no'. First key wins, last value wins — a write to an existing key overwrites the value and leaves the stored key object exactly where it was, because there is no reason to touch it and touching it would cost a write. Harmless when the merge was intended. Not harmless when it was not: that is why the tally on line one is legended True/False, and a dashboard rendering that legend will happily report a boolean breakdown of integers.

Refinement one, so none of this reads as a quirk of bool. It is the numeric tower, and it is wider than three types. Checked on this build, Decimal('1'), Fraction(1, 1) and complex(1, 0) each compare equal to 1 and each hash to 1, so a dict written with all six of them keeps a single entry — labelled Decimal('1'), since that one arrived first. What does not merge is the string: '1' == 1 is False and their hashes differ, so '1' is a genuinely separate key. The dividing line is equality, never appearance.

Refinement two: True is not imitating 1. isinstance(True, int) is Truebool is a subclass of int, and True is the integer 1 with a different repr bolted on. So the counter above is not a display bug. Three of those five items genuinely counted as the integer 1 and two counted as 0; the legend reads True/False only because those objects arrived first and kept their slots.

Refinement three, and it is the one that keeps “equal keys are one key” from hardening into a half-truth. The map does not reach a slot by equality alone, and it does not test equality first when it gets there. Watch the last two lines. float('nan') is equal to nothing at all, itself included — nan == nan is False — and since Python 3.10 a nan's hash is derived from the object itself, so two separately built nans do not even hash alike; line five prints False for that too. A freshly built nan therefore fails on both counts at once. It computes a different address, arrives at a different bucket, finds nothing there, and equality is never consulted: float('nan') in {nan: 1} is False. Notice that this leaves the law untouched — those two nans neither hash alike nor compare equal, so they are honestly two different keys. Yet {nan: 1}[nan] still hands back 1, because that is the very same object: same hash, same bucket, and the slot check finds that the stored key is the key you passed in. So refine the anatomy from the trace above: a jump, an identity compare, a cached-hash compare, and only then the full equality compare. Identity really is first — it is the order written in CPython 3.12's dictobject.c, where ep->me_key == key is tested before ep->me_hash == hash, and it is checkable from here: store a key whose __eq__ raises, read it back with that same object, and the value comes home without __eq__ ever firing. Labelling this honestly — it is a corner, not everyday weather. Outside floating-point NaN you will not meet a key that disagrees with itself.

The habit that falls out is small and worth keeping. Wherever a map's keys can arrive as a mix — flags off a form, answers off a survey, a column a database driver hands back as 1 on Monday and True on Tuesday — normalise at the door with str(k), int(k), or a small enum, so that the merge is a decision you made rather than one you discover later in a tally that quietly folded two categories into one. Now back to the buckets: the widget below lets you watch one key at a time choose its 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. An empty table sits at 0, and a half-full one at 0.5. As it climbs toward 1.0 the free slots vanish and the probe chains lengthen, so the beautiful O(1) rots into an O(n) scan. The map never lets it get that close. CPython resizes the moment the table would cross two-thirds full. It doubles the bucket array, then rehashes every existing key into the bigger table. It has to, because each key's home bucket is computed as hash % N, and N is exactly the number that just changed.

Let's make that two-thirds concrete with the smallest table. A fresh dict has 8 buckets. Drop in 5 keys and the load factor is 5/8, which is 0.625 — under the line, so nothing happens. Add a sixth key and you'd sit at 6/8, which is 0.75. That crosses two-thirds, so before the sixth key even settles, CPython doubles the array to 16 buckets and rehashes everything. Now those 6 keys live in 16 slots, a load factor of 0.375, and there's room to breathe again. That's the rhythm of the whole structure: fill toward two-thirds, double, fall back to about a third, and repeat.

You can actually watch this happen with sys.getsizeof, which reports how many bytes a live object occupies. So I inserted integers one at a time and printed the size only at the moments it 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, and the table doubles every single time it crosses the line. An empty dict is a bare 64-byte header with no bucket array at all. The first key conjures an 8-slot table that jumps the size to 224 bytes. From there each resize copies every key into a fresh, larger, mostly-empty array, so that copy is O(n). But it happens rarely and the table doubles each time, so the cost amortizes to O(1) per insert. It is the same amortized bookkeeping as the list's overallocation back in Volume 3, where you pay a big bill occasionally to keep every other insert instant.

Here's why that "amortizes to O(1)" is a theorem, not a hope. When you grow to n entries, the biggest copy moved about n keys. The one before it moved about n/2, the one before that n/4, and so on downward. Add that whole chain up: n + n/2 + n/4 + n/8 + … never climbs past 2n. So building an n-entry dict from empty costs at most about 2n copy operations in total, spread across n separate inserts. Divide it back out and that is roughly two copies per insert on average, a constant that does not grow with n. The doubling is what makes the geometric sum collapse to 2n. If the table instead grew by a fixed 8 slots each time, that sum would balloon to O(n) per insert and the whole guarantee would die.

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 price is space. Look back at the layout for a moment. A big fraction of the buckets are deliberately empty, which is just the load factor at work. And each occupied bucket doesn't only hold a value — it holds the hash, plus a reference to the key, plus a reference to the value. A list, by contrast, stores one 8-byte reference per element and nothing else. So the dict stores far more per entry and keeps spare empty slots on top. Here's the same data laid out 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, or 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, about ~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.

Where does that ~37 bytes a slot actually go? Three of the fields are the ones we already named: the cached hash, the reference to the key, and the reference to the value. On a 64-bit build each is 8 bytes, so that is 24 bytes of real payload for every live entry. The remaining ~13 bytes are the tax you pay for speed. Part is a separate sparse index that maps a hash to an entry, and part is the empty headroom the load factor deliberately keeps free. So even a full-looking dict is quietly carrying its own emptiness on the books. That is the space-for-speed trade read straight off in bytes: 24 that hold your data, ~13 that hold the single jump open.

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:

A concrete pair makes the rule stick. Say you're mapping grid positions to whatever sits on them. A coordinate written as a tuple, (3, 7), is a perfectly good key. A tuple of two ints is frozen, so its hash never moves. Write that same coordinate as a list, [3, 7], and Python raises TypeError: unhashable type: 'list' the instant you try to use it as a key. Same two numbers, same meaning to you. The difference is that the list could grow a third element later and change its hash, and the map can't allow a key whose address might drift.

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 exactly what a mutable key would do, and suppose the map let you use a mutable object whose hash tracked its own contents. You store value under it and it lands in bucket 4, and a moment later you reach in and mutate that key. Now it hashes to bucket 7 instead. So when you ask the map for that key again, the lookup walks straight to bucket 7, finds nothing waiting there, and simply reports the key as missing. Meanwhile 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.)

So what do you do when the thing you want to key by really is a list? You freeze a copy of it. Turn the list [3, 7] into the tuple (3, 7), or turn a set into a frozenset, and use that frozen version as the key. You're making a promise the map can rely on: this key's contents are fixed, so its bucket is fixed. That's the whole contract in a single line. A key may be anything at all, as long as it swears never to change while the map is holding it.

There is a deeper rule hiding underneath "immutable", and it is worth naming out loud: two keys the map treats as equal must produce the same hash. Think for a second about why that has to hold. The map finds a key by hashing it to a bucket. So if two equal keys hashed to different buckets, storing under one and looking up with the other would miss. The map would swear that a key it is actually holding isn't there. This is the __hash__ and __eq__ contract, and Python's built-in immutable types honor it for free. But when you write your own class and override __eq__ to compare by contents, you must override __hash__ to match. Otherwise you quietly break the invariant every dict silently relies on. Equal keys, equal hashes — that is the whole promise, and a frozen key is just the easiest way to keep it.

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 completely unrelated numbers back. But hash(42) came back as 42 every single time. String hashes are salted per process, which is PYTHONHASHSEED quietly at work. Integer hashes are not, because integers aren't the flooding target. So within one run the same key always lands in the same bucket, but a different run can send it somewhere else entirely. That is also why you should never persist a Python hash to disk and expect it to match on the 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

That O(1) is really an average, holding only under a good hash and a load factor kept below 2/3. Adversarial or pathological keys can still drag a single lookup all the way down to O(n). Python defends that average with hash randomization and periodic resizing, so the guarantee is engineered, not automatic.

So when does that worst case actually bite you? Almost never by accident, because Python's per-process salt scrambles string keys differently on every run, so an attacker can't precompute a pile of keys that all collide. But you can still shoot yourself in the foot. Define a custom class and give it a lazy __hash__ that returns the same constant for every instance. Now every key lands in one bucket, and the dict quietly degrades into a linked list you have to probe end to end. The lesson is exact: a dict is O(1) because the hash spreads keys out, so a hash that doesn't spread throws the whole guarantee away.

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." Once you see it, you see it constantly. Every dict you write, obviously. But also plenty you don't write yourself. Database indexes use it: a hash index turns a WHERE-clause value straight into a row location, with no table scan. Redis and memcached are whole databases that are essentially one giant hash map living in RAM, serving billions of lookups a second. Deduplication drops everything into a set, and collisions reveal the duplicates. A compiler's symbol table maps every variable name to its type and address as it parses your code. And memoization/caching leans on it too: @lru_cache is a dict keyed by your function's arguments.

THE STDLIB TOOLBELT · the dict power surfacethe four moves that turn “I have a dict” into code worth reading
d.get(key) # the value, or None -- never raises d.get(key, default) # ... or a fallback you choose d.setdefault(key, []) # read-or-create, in ONE lookup from collections import Counter, defaultdict Counter(iterable) # every tally, already written for you .most_common(n) # the top n, sorted, as (item, count) pairs groups = defaultdict(list) # a missing key CONJURES list() groups[key].append(x) # ... so grouping needs no test at all tally = defaultdict(int) # ... or 0, when you want to count by hand
.get(k, default)A read that cannot raise. d[k] on a missing key gives KeyError; .get hands back your fallback. Use the brackets when absence is a bug, and .get when absence is expected — the choice documents your intent.
.setdefault(k, [])“Give me the list at k, creating an empty one if there is none.” It costs one hash-and-jump, where the if k not in d: d[k] = [] dance costs three. Careful: the default is built on every call, so keep it cheap.
defaultdict(list)The grouping idiom. dd[k].append(x) never needs a test, because the factory runs on any miss. What that costs you is in the second tripwire — and it is not obvious.
CounterA dict subclass whose missing keys read as 0 without being created. Counter(words) is the whole word-frequency program from Volume 1 in one call, and .most_common(n) does the sorting too.
the key must be frozenStrings, numbers, tuples of immutables: fine. {["x", "y"]: 1} raises TypeError: unhashable type: 'list' — section 05's contract, enforced at the door. Freeze a list into a tuple and it is a legal key again.
.keys() · .items()Live views onto the table, not snapshots — they see changes as they happen. Convenient, and the exact reason the first tripwire fires.
INPUTfrom collections import Counter, defaultdict

words = ["apple", "avocado", "beet", "cherry", "cabbage", "apple", "beet", "apple"]

# 1. THE MANUAL GROUPING - three lines of bookkeeping you write every time
by_letter = {}
for w in words:
    first = w[0]
    if first not in by_letter:        # the "does the bucket exist yet?" dance
        by_letter[first] = []
    by_letter[first].append(w)
print("manual     :", by_letter)

# 2. setdefault - the same thing, one line, ONE lookup
sd = {}
for w in words:
    sd.setdefault(w[0], []).append(w)
print("setdefault :", sd)

# 3. defaultdict - the bucket is conjured by the factory, no test at all
dd = defaultdict(list)
for w in words:
    dd[w[0]].append(w)
print("defaultdict:", dict(dd))
print("all three equal:", by_letter == sd == dict(dd))

# 4. .get - a read that cannot raise
print("get('a'):", by_letter.get("a", []), "| get('z', []):", by_letter.get("z", []))

# 5. Counter - tallying, already written for you
c = Counter(words)
print("Counter     :", c)
print("most_common(2):", c.most_common(2), "| c['kiwi'] ->", c["kiwi"])
OUTPUTmanual     : {'a': ['apple', 'avocado', 'apple', 'apple'], 'b': ['beet', 'beet'], 'c': ['cherry', 'cabbage']}
setdefault : {'a': ['apple', 'avocado', 'apple', 'apple'], 'b': ['beet', 'beet'], 'c': ['cherry', 'cabbage']}
defaultdict: {'a': ['apple', 'avocado', 'apple', 'apple'], 'b': ['beet', 'beet'], 'c': ['cherry', 'cabbage']}
all three equal: True
get('a'): ['apple', 'avocado', 'apple', 'apple'] | get('z', []): []
Counter     : Counter({'apple': 3, 'beet': 2, 'avocado': 1, 'cherry': 1, 'cabbage': 1})
most_common(2): [('apple', 3), ('beet', 2)] | c['kiwi'] -> 0
TRIPWIRES
  • Change a dict's size while iterating it and Python stops you dead: both del d[k] and d[k.upper()] = 0 inside a for k in d: raised RuntimeError: dictionary changed size during iteration. That is not fussiness — an insert can trip the two-thirds resize from section 03, which rehashes every key mid-walk, and the loop would silently visit some keys twice and others never. The fix is one word: iterate list(d), a snapshot of the keys taken before you start.
  • On a defaultdict, merely reading a missing key writes it. Measured: len(dd) was 0, we read dd['ghost'] and got [], and len(dd) was then 1. A Counter does not do this — len(c) stayed 2 across reading c['ghost']. So a defaultdict you only query grows quietly forever; use dd.get(k) or k in dd when you are asking rather than building.
  • Since 3.7 a dict remembers insertion order — and that is not sorted order. Inserting zoe, adam, mia gave list(scores) = ['zoe', 'adam', 'mia'] while sorted(scores) gave ['adam', 'mia', 'zoe']. The dict records arrival, never rank. If you want rank, say sorted() and say it out loud.
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. It's 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.

NOW FIND THE FIRST CHARACTER THAT STANDS ALONEone tally, one walk — and the walk is the half everybody forgets
The drill. Given a string, return the first character that appears exactly once, or None. In 'swiss' that is 'w'. In 'aabbcc' there is no answer at all.

The version to beat. The instinct is for ch in s: if s.count(ch) == 1: return ch. It is correct and it is a trap: every .count re-scans the whole string, so an n-character string does n scans — O(n²) hiding behind one clean line.

Two passes, and neither is optional. Pass one builds a Counter. Pass two walks the original string and returns the first character whose tally is 1. Before you write it, argue with yourself about pass two: the Counter already remembers insertion order, so why not just read its first key with a count of 1? Try it on 'swiss' and find out what “insertion order” actually means here.

The check: run 'swiss', 'aabbcc', '', 'z', 'python', 'redivider' and 'aabbcdc' against the brute-force version and assert every answer matches — including the empty string, which is where most first drafts crash. Then time both on 20,000 characters and see the complexity classes separate.
show the solution
from collections import Counter
import timeit

def first_unique(s):
    """The first character that appears exactly once - or None."""
    counts = Counter(s)          # PASS 1: one walk, every tally landed by hash
    for ch in s:                 # PASS 2: walk the ORIGINAL, in order
        if counts[ch] == 1:      # each test is one jump, not a re-scan
            return ch
    return None

def brute(s):
    """The O(n^2) version, kept only to check the fast one."""
    for i, ch in enumerate(s):
        if s.count(ch) == 1:     # .count re-scans the whole string. Every time.
            return ch
    return None

cases = ["swiss", "aabbcc", "", "z", "python", "redivider", "aabbcdc"]
for s in cases:
    a, b = first_unique(s), brute(s)
    print(f"  {s!r:12} -> {a!r:6}  (brute {b!r})  {'ok' if a == b else 'DIFFER'}")

print("Counter('swiss') =", Counter("swiss"))

setup = ("from __main__ import first_unique, brute\n"
         "import random; random.seed(3)\n"
         "s = ''.join(random.choice('abcdefghij') for _ in range(20_000)) + 'Q'")
tc = timeit.timeit("first_unique(s)", setup=setup, number=50) / 50
tb = timeit.timeit("brute(s)", setup=setup, number=3) / 3
print(f"20,001 chars: Counter two-pass {tc*1e3:6.2f} ms   |   brute .count {tb*1e3:7.1f} ms")
print(f"speedup: {tb/tc:.0f}x")

#   'swiss'      -> 'w'     (brute 'w')  ok
#   'aabbcc'     -> None    (brute None)  ok
#   ''           -> None    (brute None)  ok
#   'z'          -> 'z'     (brute 'z')  ok
#   'python'     -> 'p'     (brute 'p')  ok
#   'redivider'  -> 'v'     (brute 'v')  ok
#   'aabbcdc'    -> 'd'     (brute 'd')  ok
# Counter('swiss') = Counter({'s': 3, 'w': 1, 'i': 1})
# 20,001 chars: Counter two-pass   1.44 ms   |   brute .count   446.8 ms
# speedup: 310x
#   (wall-clock wanders a few percent run to run - a repeat gave 435.4 ms
#    and 302x. The few percent is noise; the 300-fold is the law.)

# WHY PASS TWO IS NOT OPTIONAL. Look hard at that Counter line.
# Counter('swiss') prints as {'s': 3, 'w': 1, 'i': 1} - and 's' is first.
# The dict remembers the order keys were FIRST SEEN, and 's' was seen first.
# That is arrival order, not "appears once" order, and the two are different
# questions. Read the answer off the Counter alone and you would still have
# to skip 's', which means you would still be doing pass two - just badly,
# over the wrong sequence. The string knows the order; the tally knows the
# counts; the answer needs both, which is exactly why it takes two walks.
#
# WHAT THE TWO PASSES COST. Pass one hashes each character once and lands a
# tally: n jumps. Pass two hashes each character once more and reads a
# tally: at most n jumps. So 2n hash-and-jumps, which is O(n) - against the
# brute force's n scans of n characters, O(n^2). The measurement above is
# that gap in the flesh: 1.44 ms against 446.8 ms on 20,001 characters,
# about 310x. Ten times the characters would widen it by another ten.
#
# THE SHAPE TO CARRY AWAY. "Tally in one pass, then decide in a second pass
# over the original order" is one of the most reusable moves you own. First
# repeated character, first non-repeated word, the first duplicate in a log,
# the most recent unique visitor - all the same two passes, all resting on
# the fact that a tally lookup is one jump and never a search.

One honest caveat before you reach for a dict on reflex. The single-jump lookup earns its keep when you look things up by key often and the collection is large. For a handful of items, a plain list you scan is just as fast in practice and far lighter in memory — remember the dict cost four to five times the bytes per item. And a dict answers "what's at this key?" brilliantly, but it's the wrong tool for "what's the smallest?" or "give these back to me in order." Reach for the hash map when the question is reach by label. Reach for something else when the question is really about order or ranking.

WATCH AN EXPERT THINKthe ten most common words in a stream of two million
Words arrive and keep arriving — search queries, log lines, tags, the tokens of a corpus — and at the end one question has to be answerable: which ten occur most often? Concretely below: 2,000,000 words walked exactly once, about 110,000 of them distinct, and k = 10. Two labels before we start, so nothing here reads as more than it is. The corpus is synthetic and Zipf-shaped — a handful of words carrying most of the mass over a very long tail — because that is the shape real text has and because a fixed seed makes every number below reproducible; it is held in a list only so the timings measure counting rather than random-number generation. And the answer wanted here is exact. There are sketch structures that answer top-k approximately in a fixed number of bytes however long the stream runs, and they are the right tool once the vocabulary itself stops fitting in memory. That is not this problem. Nothing in the statement says heapq, and nothing in it says Counter either. So rewind to before you read this chapter and watch somebody arrive, because the wrong turn on the way is the one nearly everybody takes, and it is not a foolish one.
FIRST INSTINCT
Count, then sort. The counting half is the part this chapter has already made obvious: Counter walks the stream once and every word is a single hashed jump to its tally, so two million words become 109,425 pairs in one pass while nothing but the tallies is ever held. Then rank them — sorted(counts.items(), key=itemgetter(1), reverse=True)[:10] — and slice ten off the front. It is correct on the first attempt, it is one line, and it is what most experienced people write without pausing. It is also genuinely quick: 14.2 ms to rank all 109,425 words in the run below. There is no obvious seam to pull at. It feels finished.
WHY IT STALLS
Count what that line actually computed. To hand you ten words it decided the exact rank of all 109,425 — it settled whether the 40,000th-most-common word beats the 40,001st, a fact nobody asked for and nobody will ever read. A total order over the entire vocabulary, of which you consume the first ten and discard 109,415. The complexity says the same thing more quietly: O(d log d) comparisons where d is the number of distinct words, and log d has no idea that k is ten.

Then price the half that is not time, because that is the half that takes services down. sorted() cannot answer until it has materialised every pair into one list, so its peak allocation is proportional to the whole vocabulary, and it lands at the exact moment your process is already holding the counts. Measured with tracemalloc in the run below: 8,177 KB of transient peak at 109,425 distinct, and 45,349 KB when the same code meets a 621,953-word vocabulary. Now say precisely what that is, because it is easy to oversell. It is not the only quantity here without a ceiling — the counts themselves grow with the vocabulary too, and the last line of the run prices them at 44,173 KB for that same big case. It is the only avoidable one: the sort's peak lands on top of the counts you are already holding and very nearly doubles the worst moment of the whole program, in exchange for an answer ten pairs long. A one-line answer that doubles your peak is a different kind of problem from a slow one.
THE TELL
So stop asking how to sort the counts and ask how many of them you are allowed to forget. Ten. You need ten, so hold ten: walk the pairs once carrying a k-sized heap, and each pair either beats the smallest of the ten currently held — in which case it displaces it — or it is dropped and never thought about again. Comparisons fall from O(d log d) to O(d log k), and log₂ of 109,425 is about 17 against log₂ of 10 at about 3.3. Memory falls from O(d) to O(k), which is those 45 megabytes collapsing to one kilobyte.

Say the split out loud and it becomes a rule you can carry into problems that look nothing like this one: counting is the dict's verb and selection is the heap's. The dict earns everything on the pass that turns 2,000,000 words into 109,425 tallies at one jump apiece — that is precisely the machinery sections 01 through 03 built. It has nothing whatever to offer on the ranking, and the reason is the machinery itself: a slot is chosen by hash(key) % N, arithmetic that reads the key and knows nothing at all about the tally sitting beside it. A count of 84 and a count of 162,791 land wherever their keys send them. Ask a hash map for “the largest” and it has no move available but to look at every one of them.

Then the deflating and genuinely useful punchline: the call is already in your hands, and you have probably typed it without noticing which of the two designs you were choosing. In CPython 3.12, Counter.most_common(n) is literally return heapq.nlargest(n, self.items(), key=_itemgetter(1)), while most_common() with no argument is sorted(self.items(), key=_itemgetter(1), reverse=True). Those two branches are the instinct and the tell, sitting four lines apart in the standard library. The expertise here is not a function you had never heard of. It is knowing that passing k and leaving it off ask for two different algorithms.

One correction before the code, because a tell that fires on everything is not a tell. nlargest is not a magic wand, and its own source says so: if n >= size: return sorted(iterable, key=key, reverse=True)[:n]. Ask it for all 109,425 and it hands the job straight back to sorted — measured at 14.8 ms against the plain sort's 14.2 ms, a hair slower rather than faster, because it runs the identical sort and then copies the whole sorted list in order to slice it. The tell fires on k much smaller than d, never on the phrase “top k”.
import heapq, random, sys, time, tracemalloc
from collections import Counter
from operator import itemgetter

K = 10

def corpus(tokens, vocab_size, seed=7):
    """A Zipf-shaped word list. Held in a list ONLY so the timings below measure
    counting rather than random-number generation -- Counter.update() will just
    as happily eat a file or a socket one word at a time and hold none of it."""
    rng = random.Random(seed)
    vocab   = [f"w{i:06d}" for i in range(vocab_size)]
    weights = [1.0 / (i + 1) for i in range(vocab_size)]   # a few words dominate
    return rng.choices(vocab, weights=weights, k=tokens)

def by_sort(c, k):  return sorted(c.items(), key=itemgetter(1), reverse=True)[:k]  # the instinct
def by_heap(c, k):  return heapq.nlargest(k, c.items(), key=itemgetter(1))         # the tell

def ms(fn, reps=5):
    t0 = time.perf_counter()
    for _ in range(reps): r = fn()
    return (time.perf_counter() - t0) * 1000 / reps, r

def peak_kb(fn):
    tracemalloc.start(); tracemalloc.reset_peak(); fn()
    p = tracemalloc.get_traced_memory()[1]; tracemalloc.stop()
    return p / 1024

def held_kb(c):        # what the counts ALREADY cost: the table plus the key strings it points at
    return (sys.getsizeof(c) + sum(map(sys.getsizeof, c))) / 1024

words = corpus(2_000_000, 120_000)
t0 = time.perf_counter()
counts = Counter()
counts.update(iter(words))                   # ONE pass. One hashed jump per word.
t_count = (time.perf_counter() - t0) * 1000

t_sort, top_sort = ms(lambda: by_sort(counts, K))
t_heap, top_heap = ms(lambda: by_heap(counts, K))
t_pack, top_pack = ms(lambda: counts.most_common(K))
p_sort, p_heap   = peak_kb(lambda: by_sort(counts, K)), peak_kb(lambda: by_heap(counts, K))

print(f"{len(words):,} words walked once  |  {len(counts):,} distinct  |  k = {K}")
print(f"count   Counter.update(stream)   {t_count:7.1f} ms                 <- one pass, unavoidable")
print(f"select  sorted(...)[:10]         {t_sort:7.1f} ms   peak {p_sort:8,.0f} KB")
print(f"select  heapq.nlargest(10, ...)  {t_heap:7.1f} ms   peak {p_heap:8,.0f} KB"
      f"   ({t_sort/t_heap:.1f}x faster, {p_sort/p_heap:,.0f}x lighter)")
print(f"select  counts.most_common(10)   {t_pack:7.1f} ms                 <- the same call, packaged")
print("identical top-10 from all three:", top_sort == top_heap == top_pack)
print("top 5:", top_sort[:5])

t_all, _ = ms(lambda: heapq.nlargest(len(counts), counts.items(), key=itemgetter(1)), reps=5)
print(f"when k = all {len(counts):,}:      nlargest {t_all:6.1f} ms  vs  sort {t_sort:6.1f} ms"
      "   <- no win left; nlargest just calls sorted")

big = Counter(corpus(6_000_000, 1_000_000, seed=11))
tb_s, a = ms(lambda: by_sort(big, K), 3); tb_h, b = ms(lambda: by_heap(big, K), 3)
pb_s, pb_h = peak_kb(lambda: by_sort(big, K)), peak_kb(lambda: by_heap(big, K))
print(f"scale up to {len(big):,} distinct:  sort {tb_s:6.1f} ms / {pb_s:8,.0f} KB"
      f"  |  nlargest {tb_h:6.1f} ms / {pb_h:5,.0f} KB  | same answer {a == b}")
print(f"   and the counts you are ALREADY holding: {held_kb(big):,.0f} KB"
      "  <- also grows with the vocabulary; the sort's peak stacks on top of it")
2,000,000 words walked once  |  109,425 distinct  |  k = 10
count   Counter.update(stream)      83.7 ms                 <- one pass, unavoidable
select  sorted(...)[:10]            14.2 ms   peak    8,177 KB
select  heapq.nlargest(10, ...)      4.4 ms   peak        1 KB   (3.2x faster, 10,519x lighter)
select  counts.most_common(10)       4.5 ms                 <- the same call, packaged
identical top-10 from all three: True
top 5: [('w000000', 162791), ('w000001', 81357), ('w000002', 54866), ('w000003', 41109), ('w000004', 32658)]
when k = all 109,425:      nlargest   14.8 ms  vs  sort   14.2 ms   <- no win left; nlargest just calls sorted
scale up to 621,953 distinct:  sort  491.7 ms /   45,349 KB  |  nlargest  160.0 ms /     1 KB  | same answer True
   and the counts you are ALREADY holding: 44,173 KB  <- also grows with the vocabulary; the sort's peak stacks on top of it
trigger: reach for a bounded heap the moment you want the best k out of many and do not want the order of the rest. Look for that shape in the sentence rather than in the data — “the ten slowest endpoints”, “the five largest files on the volume”, “the twenty accounts sending the most mail”, “the three nearest depots” — every one of them names a small k and never asks a thing about the rest, and every one of them is usually written as a full sort. Then learn the near-misses, because they are what make it a tell rather than a reflex. If the ranking itself is the product — a leaderboard the reader scrolls, a report of every term by frequency — you genuinely want the whole order, so sort, and most_common() with no argument does exactly that for you. If k is a large fraction of d there is nothing to win, and nlargest will quietly hand the work back to sorted anyway. And if the answer has to stay current while the counts keep moving, a one-shot selection is the wrong shape entirely; that wants a structure which maintains order as it goes, which is where the chapters ahead are headed.

The second habit is worth as much as the first: know which half of the job you are timing. In the run above the counting cost 83.7 ms and the selecting cost 14.2 ms, so swapping the sort for the heap moved the whole job from 97.9 ms to 88.1 ms — about 10%, not the 3.2× the middle line advertises. That is worth saying plainly, because the middle line is the one people quote. The honest headline of this pivot was never the speed: it is the 45 megabytes that stopped being allocated on top of the 44 you were already holding, and the fact that the number no longer grows with the vocabulary. Wall-clock wanders a few percent from run to run; the shape does not. When you quote a win, quote the part that scales.
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? →

SAY IT BACKthe chapter in five breaths
  1. A hash map makes the key compute its own addressbucket = hash(key) % N — so a lookup jumps to one slot instead of walking n of them, and that single jump, whose cost has nothing to do with how many entries are inside, is the entire O(1).
  2. Collisions are arithmetic rather than accident — squeeze a giant key space through % N and keys must share slots, already more likely than not by the fourth key in an 8-bucket table — so landing in a bucket proves nothing: the map compares the cached hash, then the key itself, and probes forward along the exact path the insert took.
  3. The health meter is the load factor, entries over buckets, and CPython will not let it climb: cross two-thirds full and the table doubles and every key is rehashed, because each address was computed from N and N just changed — measured resizing at 6, 11, 22, 43 and 86 entries.
  4. You buy the jump with bytes: roughly 37 per slot against a list's 8 per element, 24 of them the cached hash and the two references and the other ~13 the sparse index plus the emptiness the load factor keeps free — four to five times a list's memory, spent on purpose.
  5. A key must be frozen because its hash is its address, and beneath that sits the real law: equal keys must hash equal, so two keys that compare equal are one key (NaN aside, which is equal to nothing at all) — which is why 1, 1.0 and True share a single slot, and why mutating a key after insertion strands its value in a bucket nothing will ever visit again.
reach for this when… the sentence you are trying to implement contains the words by, per, already seen, or how many of each. That is the diagnostic worth carrying out of here, because it reads the specification rather than the data, and it fires before you have written a line: “price per SKU”, “fetch the account by id”, “skip anything already seen”, “how many of each status code”. Each of those has a dict or a set underneath it whether or not anyone says so. And the most common sentence of all carries two of those phrases at once — “the ten busiest endpoints per hour” — which is worth answering in two parts out loud: count with the dict, then hand the ranking to something else.
You already owned the pieces: Volume 1 opened an array and found a contiguous block of 8-byte references reached by one base + i × width computation, and that address arithmetic is the “jump” in single jump — a hash map is that same array, with a function bolted on the front that turns a name into an i. Chapter 37 gave you the arithmetic itself when a coat-check ticket decided where the coat went; here the very same trick decides where your data lives. Chapters 51, 52 and 53 gave you the linear family — stack, queue, deque — and every one of them still reaches its contents by position, which is exactly the spell this chapter breaks. And Volume 3 supplied both halves of the bargain: the amortised doubling that made a list's occasional O(n) copy vanish into O(1) per insert is the identical bookkeeping behind the two-thirds resize, and the cache story that made pointer-chasing stall is why CPython keeps its buckets in one flat array and probes forward instead of chaining.
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
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked