◈ 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.

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.

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
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked