python/volume-1/chapter-07ch 07 / 64 · 22 min

07Hash tables

In Chapter 6 we lined our values up in a row of slots and reached each one by its position — box 0, box 1, box 2. That works right up until the day you know a song's name but not its number. Then a list has only one move left: walk the whole row asking is it you? is it you? — a million questions for a library of a million tracks. This chapter kills that walk. I want to go slowly, because there's a genuinely beautiful trick here that most tutorials skip past. Here's the plan. We hand every key to hash() and watch it collapse into a single number. That number picks a bucket. We see what happens when two keys land in the same one. And we find out why the whole thing quietly trades RAM for speed. The one question we keep coming back to is the one that matters: how does a dict jump straight to a value without ever scanning for it? By the end you'll ask for library["Titanium"] and get 245 back in a single hop. You'll know exactly why a list can never be a key while "Blinding Lights" always can. And you'll rip the values out of a dict and find a set was hiding inside it the whole time. There's no magic in any of it — just arithmetic standing in for a search.

The problem: finding one song in a million hash(): every key becomes a number Buckets and collisions Load factor: RAM traded for speed
scroll to begin

01The problem: finding one song in a million

Let's start with the one thing a list simply cannot do. Picture your whole music library — a million tracks — living in a list of (title, seconds) pairs. Now answer one small question: how long is "Blinding Lights"? You know the name. The list doesn't care. It only ever knows positions — box 0, box 1, box 2 — never names. So watch what Python is forced to do. It walks the row, holds each pair up to the light, and asks the same dull question: is it you? is it you? is it you? The pair you want could sit anywhere, so in the worst case it asks all one million times before it finds the match. And here's the sting: the answer isn't remembered. Ask for that same song's length a second time and you pay the full million-question toll again. That cost grows with the pile, every single time, and it has a name: O(n).

A dictionary (dict) refuses to play that game. Instead of going position by position, it stores key → value pairs and fetches by key in a single move — no walking, no asking. Each key is unique: a label that points at exactly one value. Hand the dict a key and the value falls out. You write one in curly braces, with a colon gluing each key to its value and commas separating the pairs. (Since Python 3.7 a dict also remembers the order you inserted things in — a small kindness the old ones didn't offer.) That one-move cost has its own name, and it's the whole reason this chapter exists: O(1), the same tiny price whether the library holds ten songs or ten million. And you've been leaning on one all along without seeing it. The name→object bindings from chapter 3 — your module's globals, and every object's attributes — are themselves stored in a dict that maps a name-string to the object's heap address. Every time you write x = 5 and later read x, a hash table is doing exactly the lookup this chapter is about to take apart.

THE ONE IDEA TO CARRY FORWARD
A list answers "where is it?" by looking. A dict answers by computing — it works out where the key must live and jumps straight there, without ever scanning. Every trick in this chapter is a variation on that single swap: trade the hunt for a calculation.
lookup.pypython
song = {"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200}

print(song["artist"])                # The Weeknd — one jump, no scan
print(song["seconds"])               # 200

# song["genre"]                      → KeyError: 'genre'  (crash!)
print(song.get("genre", "unknown"))  # unknown — the safe fetch

Two ways to ask. song["artist"] demands the value and insists the key exists. Reach for a key that was never there, like "genre", and the dict doesn't shrug and hand back a blank — it raises a KeyError and stops the program cold. When you're not sure a key is present, song.get("genre", "unknown") is the gentle version: it returns the value if the key is there, and your fallback if it isn't. No crash.

One question, two costs: scan vs. jumpfigure
list — ask every box, one by one no no no no 200 up to n checks — O(n) the title could sit in any box dict — compute the slot, then jump "Blinding Lights" hash( ) · · 200 · 1 jump — O(1) found without ever looking
Fig 1 — Same question — how long is this song? — wildly different cost. The list interrogates box after box until it stumbles on the match. The dict runs the key through a little machine called hash() (next section), lands on a slot, and jumps. One looks; the other computes.

"One jump" and "a million questions" are easy to say. It's another thing to feel the gap. Grow the library below and watch which cost moves.

Grow the library — watch only one cost growinteractive
how the cost of one lookup grows as the library grows list dict 1 up to 8,000,000 checks ≈ 130 ms 1 jump ≈ 35 ns — no matter how big
8 million songs
Drag it. Double the library and the red bar doubles — that's O(n). The green square never twitches — that's O(1). Push it to 50 million and the list needs the better part of a second while the dict is still done in tens of nanoseconds.
Fig 2 — Worst-case scan versus dict lookup, timed on one laptop: about 16 ms per million songs for the scan, a flat ~35 ns for the dict. Measured, the full-million gap is roughly 476,000×. The scan grows with your collection; the dict simply doesn't notice it got bigger.
↺ reframe: shelf vs. coat-check
A list is a long shelf — to find your coat you walk it end to end, reading every tag. A dict is a coat-check: you hand over a ticket and the attendant computes which peg it maps to and grabs it, never scanning the rack. The speed was never in searching faster. It's in not searching at all.
LOOPING A DICT GIVES YOU KEYS
for k in song: yields the keys — "title", "artist", "seconds" — not the values. Want both at once? for k, v in song.items():. Just the values? song.values(). Just the keys, spelled out? song.keys(). The bare loop defaults to keys because the key is the thing you look up by.
Wait — if the dict never looks, how does it even know a key is missing? A scan can be sure "Blinding Lights" isn't there only after checking all million boxes. The dict raised KeyError on "genre" in microseconds, without a scan. So it must be computing not just where a key would live but also whether anything's actually parked in that spot — and doing both in one shot. That double trick is exactly what the next section unpacks.

✗ The myth

"A dict is just a list of pairs that Python searches really cleverly — a turbocharged scan."

✓ The reality

There's no scan to turbocharge. A dict computes the key's location and goes straight there. Its speed doesn't come from a faster search; it comes from replacing search with arithmetic — which is why it stays instant no matter how much you pour in.

The deeper cut — why "one jump, O(1)" is a labelled simplification

"One jump, always O(1)" is a labelled simplification — true enough to build your whole mental model on, and we'll refine it exactly once, here. The honest statement is that a dict lookup is O(1) on average (technically amortised). Two things hide behind that average. First, the computed slot is occasionally already taken by a different key — a collision — so the dict probes a couple of nearby slots to sort it out. The true cost is "one jump, plus a rare short shuffle." Second, when the table fills up, Python quietly builds a bigger one and re-files every entry. That one move is expensive, but spread across all the cheap lookups it averages out to constant. In a pathological case where every key collides, a dict can even degrade to O(n) — the very scan we were fleeing. In practice that essentially never happens, because the slot a key lands in is chosen by a good hash(), which scatters keys so evenly that collisions stay rare. So "one jump" isn't a lie. It's the average you'll actually feel, and the whole reason it holds is the function we're about to meet.

The dict's entire superpower rests on turning a key into a slot number without looking. So how does "Blinding Lights" — a string, not a number — become an address? Meet hash(): every key becomes a number. →

Wait — the list doesn't just walk slowly, it walks slowly every single time. Ask "how long is Titanium?" and it compares its way down the row; ask the exact same question a second later and it has remembered nothing — it pays the full walk again. A dict computes the address instead of finding it, so the second question is as cheap as the first. It's speed you keep, not speed you re-buy.
TWO WAYS TO ASK, TWO WAYS TO FAIL
song["genre"] insists the key exists — miss and it doesn't hand back a blank, it raises KeyError and halts the whole program. song.get("genre") asks the same question gently: present → the value, absent → None (or a default you name). Identical lookup, opposite manners the moment the key is missing.
Wait — the rule is only ever about the key. A value can be anything — a list, another dict, a whole nested playlist, even the dict itself: d = {}; d["self"] = d is perfectly legal. Only the left of the colon has to behave, because only the key is used as an address. Values are cargo; the crate never inspects what you pack inside.
A DICT HAS NO "FIRST"
A dict isn't just a faster list, and d[0] proves it. In a list that means "the first box." In a dict it means "the value filed under the key 0" — and if you never stored that key, it's a KeyError, not the first item. A dict has no positions at all, only names; there is nothing to be "first."
Trace3 step the machine — idea · code · memory move together
The list walk — why find by name costs O(n)
A list knows only positions, never names. To answer “how long is Titanium?” it must open box after box, comparing every title — a cost that grows with the pile. A dict replaces the whole walk with one computed jump. Press Next and watch the tally climb.
5 songs4 comparesO(n)
The idea — walk the shelf, box by boxlist · 5 pairsinit
is this the one? title == name
🔍
comparisons 0
a dict would already be done — 1 jump, O(1)
In plain words
Under the hood
SETALU · the operation
Program · find.py
variables · type
Memory · registers + object
registers — the running state
library list · 5 (title, secs) tuples — indexed by position only
what's happening
beat 1 / 1

02hash(): every key becomes a number

Chapter 0 left you with a superpower and a catch. The superpower: RAM can leap to box 6012 without walking the street, because the address is a calculation, not a searchstart + i × 4 and you're there. The catch: that trick only works when your key already is a number. A list is happy, since lengths[3] is arithmetic. But you don't want to ask for song #3. You want to ask for "Blinding Lights", and a title is not a house number. So here's the move that unlocks the whole chapter. What if we could turn any key — a string, a pair, anything — into a number first, and then do the address arithmetic?

That converter is a hash function. Feed it any object and it doesn't just glance at it. It walks every byte of the contents, mixing each one into a running accumulator, so a single changed character avalanches into a completely different result. A string is folded byte by byte over its UTF-8 encoding (CPython uses an algorithm called SipHash); a tuple folds together the hashes of its own elements, in order, into one number. Because the fold reads the contents and nothing else, it obeys one iron rule: the same value always comes out as the same number, every time you ask, within one run of your program. For the same reason, equal contents give equal numbers, while ("a","b") and ("b","a") come out different, because order is folded in too. Python hands you the converter as a built-in called hash(). The surprising part, the part that makes dictionaries possible, is that it works on things that look nothing like numbers.

THE ONE IDEA TO CARRY FORWARD
A dictionary never searches for your key. It turns the key into a number, and the number tells it where to look. Titles, tuples, whole sentences — hash() folds each one down to a single integer, and from that integer everything else in this chapter is just the address arithmetic you already trust.
Three different keys, one funnel, three integersfigure
any object in — one integer out 245 "Titanium" ("Titanium", "David Guetta") hash() 245 itself — stable across runs -5059808785022855365 19 digits — new one every run -6967856783407273396 one number for the whole pair an int hashes to itself · a str and a tuple become huge signed integers (illustrative — yours will differ)
Fig 1 — The same converter swallows an int, a string and a tuple and returns a single integer for each. Small ints come back as themselves; text and tuples come back as gigantic signed numbers that are re-scrambled every time your program starts (that last part turns out to be a security feature — see the deeper cut).
hash.pypython
# every value crunches down to one integer
print(hash(245))                        # 245  — small ints hash to themselves
print(hash("Titanium"))                 # a huge signed int — a new one every run
print(hash(("Titanium", "David Guetta")))  # tuples hash too — one number for the pair
print(hash([200, 245]))                  # TypeError: unhashable type: 'list'  (why? see below)

Now the leap makes sense. A dict keeps an internal array of slots — call them buckets. To store "Blinding Lights" → 200, Python computes hash("Blinding Lights"), then folds that gigantic number down onto a real slot by taking the remainder modulo the number of buckets. A brand-new dict starts with 8 buckets, and a remainder modulo 8 can only ever be 07, so any 19-digit hash collapses onto a bucket that actually exists. What the bucket then stores is not the song and its length. Picture it holding two of chapter 0's 8-byte addresses: one pointing at the key object, one at the value object. (That two-pointers-in-the-bucket image is a labelled simplification. Since CPython 3.6 the bucket actually stores a small integer index into a separate dense array, and it is there that the key-pointer, the value-pointer, and a cached copy of the hash live side by side — a later deeper cut opens it up. What a bucket remembers is still just where to find the key and value; only the indirection is glossed here.) The string "Blinding Lights" and the int 200 stay wherever they were built on the heap; the bucket just remembers where to find them. To read the value back, Python runs the exact same arithmetic — same hash, same remainder, same bucket — and follows the value's address. The key becomes an address, after a little math.

Fold a number onto a bucket — its low 3 bits decideinteractive
a small int hashes to itself — so its own low bits pick the bucket 1 1 1 1 0 1 0 1 low 3 bits pick the bucket 245 & 7 = 101₂ = 5 0 1 2 3 4 5 6 7 245 hash(245) → bucket 5 245 is "Titanium" — alone in bucket 5 for now
245
Because a small int hashes to itself, the bucket is just the number's last three bits. Slide to 200 ("Blinding Lights") and it lands in bucket 0. Now slide to 8, or 16, or 248 — every one of them also lands in bucket 0, even though they're different songs. Eight buckets, 256 possible numbers: the crowding is unavoidable.
Fig 2 — Storing and looking up run the identical pipeline, so they always land in the same bucket. Only the low bits of the hash choose the slot — which is exactly why distinct keys can be forced to share one.
↻ the dict computes, it does not hunt
Almost everyone pictures a dict finding your key — a search, just mysteriously fast. It is the opposite. The dict calculates where the key must be and jumps straight there; on the happy path it never compares your key against the other keys at all. That is why a lookup in a dict of ten songs and a lookup in a dict of ten million take essentially the same time — measured, the second is about 1.01× the first. Size barely matters, because you were never scanning.
Wait — why did hash([200, 245]) throw a TypeError while the tuple hashed fine? Because a list can be changed. Picture storing something under a list key, then appending to that list: its hash would shift, the fold would point at a different bucket, and your value would be stranded in a slot nobody looks in anymore. Python refuses to open that trap. Only immutable objects — ints, strings, tuples, frozensets — are hashable and may be keys. Mutable ones (lists, dicts, sets) are barred at the door.

✗ The myth

hash() is a kind of encryption, or a magically-fast search — some clever way of scanning the keys quicker.

✓ The reality

No scanning and no secrecy. It's a one-way address calculation: turn the key into a number, fold the number to a slot, go there. You can't get the key back from the number, and nothing is ever searched.

EQUAL KEYS ARE THE SAME KEY
The iron rule has a partner: if two values are equal, they must hash equal. Since 200 == 200.0 is True, they share a hash (both 200) and therefore share a bucket — so {200: "a", 200.0: "b"} collapses to a single entry, {200: "b"}. The dict sees one key wearing two costumes, not two keys. Keep this in your pocket; it surprises everyone once.
The deeper cut — three honest refinements to "hash, then take the remainder mod 8"

One — it isn't really a remainder. Saying Python does hash % 8 is a labelled simplification: right in its result, tidier than the truth. Because a dict's table is always sized to a power of two, CPython gets the same answer with a bitmask — hash & (size - 1) — which simply keeps the low bits and throws the rest away. For any non-negative number, h & 7 and h % 8 are identical (that's why the slider could show the last three bits directly), but the mask is a single hardware instruction where division is many. Same slot, faster route.

Two — "hashes to itself" has an edge. Small ints really do return themselves, but only up to 2**61 - 1. Past that, an int folds by that same Mersenne-prime modulus, so enormous integers get scrambled like everything else. Our song lengths (0–255) are nowhere near the edge, so the picture holds cleanly for the whole chapter.

Three — why strings scramble differently every run. Integers hash predictably, but a string's hash is seeded with a secret random number chosen at startup (PYTHONHASHSEED). That's deliberate: if the mapping from text to bucket were fixed and public, an attacker could hand your web server thousands of titles engineered to pile into one bucket, collapsing every lookup back into a slow scan — a real denial-of-service trick. Randomising the seed makes those keys unpredictable. (And one last curiosity you'll trip over eventually: hash(-1) is -2, because -1 is reserved inside CPython to mean "an error happened.")

Eight buckets, endless possible keys — you saw 200 and 8 crash into the very same slot. When two keys want one bucket, what does the dict actually do about it? →

Wait — hash("Titanium") isn't one number — it's a different number every time Python starts. Run it in three fresh interpreters and you get -7326773390544303646, then -3214277875147719081, then 2792702592161139138: same eight letters, three total strangers. The scramble is reseeded at startup so an attacker can't precompute keys that all pile into one bucket. Within a single run, though, it never wavers — which is the only promise a dict needs.
TRUE IS JUST 1 IN A COSTUME
Equal values must hash equal — and True == 1 == 1.0, so all three hash to 1 and share a bucket. Build {1: "a", True: "b", 1.0: "c"} and it collapses to a single entry, {1: "c"}: the dict sees one key trying on three outfits, not three keys. hash(True) really is 1.
WHERE "HASHES TO ITSELF" STOPS
Small ints hash to themselves — hash(245) is 245 — but only up to 2**61 - 1 (about 2.3 quintillion). One step past that edge and even integers get scrambled: hash(2**61) is 1. There's a literal spot on the number line where the tidy "itself" rule runs out and the folding begins. Our song lengths (0–255) live nowhere near it.
↺ reframe: every key is arithmetic in disguise
A dict can't file the word "Titanium" — it files the number hash("Titanium") & 7, a single bucket. The string itself is just cargo dangling off that number, fetched only to double-check with ==. You can even fold a key on paper: chapter 0 taught you that "B" is really the byte 66 and "i" the byte 105, so a toy hash of "Bi" could be 66 + 105 = 171, and 171 & 7 = 3 — bucket 3, arithmetic you can check by hand. Real hash() scrambles far harder than a plain sum, but the shape is precisely this: bytes in, one number out, low bits pick the shelf. Even your most human, text-shaped keys live inside the dict as pure address math — nothing is ever spelled out and searched for; it is all computed.
Trace3 step the machine — idea · code · memory move together
Key → number → bucket — folding a hash onto a slot
A dict never searches. It runs a key through hash() to get an integer, keeps only the low three bits (hash & 7) to pick one of eight buckets, and remembers the key and value there. This trace uses an integer key, whose hash is simply itself, so you can check the fold by eye — a string like "Titanium" takes the identical last step, only after hash() first scrambles its bytes into a giant salted integer. Press Next and watch a number fold onto a slot.
8 bucketsmask & 7O(1)
The pipeline — key → hash → bits → bucketdict · 8 slotspair
key"Titanium"
,
value245
245 hash() ?
→ key0x… → value0x…
In plain words
Under the hood
SETALU · the operation
Program · hash_fold.py
variables · type
Memory · buckets + heap
registers — the running state
table list · 8 buckets — a slot is (→key, →value) or empty
str"Titanium"
@ 0x…
int245
@ 0x…
what's happening
beat 1 / 1

03Buckets and collisions

A hash function hands every key a bucket number, and lookups become a single jump instead of a search. Beautiful — until two keys are handed the same number. That's a collision, and here's the uncomfortable truth: collisions are not bad luck you can engineer away. There are only ever a handful of buckets and effectively unlimited possible keys. So by sheer pigeonhole logic — more pigeons than holes — some pair must share. Squeeze 200 songs into 128 buckets and two of them collide with certainty, no matter how clever the hash. A hash table that pretended collisions couldn't happen would be one that sometimes lost your data.

How empty can a table be and still collide? Drag songs ininteractive
A fresh dict with 1,024 empty shelves. Drag songs in — how soon do two collide? 50% chance two songs already share a shelf BUT THE TABLE IS 96% empty only 38 of 1,024 shelves used HOW FULL THE TABLE IS HOW LIKELY THAT TWO COLLIDE 96% of shelves empty — yet already ~50% likely two songs collide. Even spread assumed. The 50/50 point sits near √shelves — ~38 songs, far below "full".
38 songs
Drag it. A nearly-empty table collides shockingly early — the 50/50 point lands near √(shelves), about 38 songs for 1,024 shelves, when the table is still 96% empty. That is exactly why Python bakes probing and resizing in from the very first key: collisions aren't the exception, they're the rule.
Fig — Pigeonhole makes collisions certain once songs outnumber shelves; probability makes them early. The tiny green bar is how full the table is; the red bar is how likely two songs already collide. They cross 50/50 at ~38 keys — while 96% of the shelves sit empty.

Python's answer is refreshingly blunt: if your home bucket is already taken, probe. Step to another slot, and keep stepping until a free one turns up, wrapping past the end back to bucket 0 if you run off the edge. The newcomer simply takes a detour. The payoff comes later, at lookup, which retraces the identical steps: hash the key, jump to the home bucket, ask "is this my key?", and if not, step exactly as insertion did — until it finds the key or hits an empty slot. Same recipe in, same recipe out. That symmetry is the whole reason the detour is safe.

THE ONE IDEA TO CARRY FORWARD
A collision is not a failure — it is the expected, routine case, and open addressing turns it into a short walk. Because insertion and lookup follow the same probe route, a key that had to detour is still found in a jump or two. That is why a dict stays O(1) on average even though collisions are mathematically guaranteed.
Two songs, one home bucket — the newcomer probes onwardfigure
"Uptown Funk" · 270 s 270 & 7 = 6 "One Dance" · 174 s 174 & 7 = 6 Uptown 270 One Dance 174 0 1 2 3 4 5 6 7 both lengths land on bucket 6 — the newcomer steps to the next free slot, and lookup will retrace that exact step
Fig 03 — A collision isn't an error, it's routine. "One Dance" finds its home bucket occupied by "Uptown Funk" and probes one slot onward to 7. The table stays perfectly correct as long as everyone follows the same route in and out.

But wait — if the address was computed from the key, why does lookup bother asking "is this my key?" at all? Because a collision may have parked a different key in your home bucket. Arriving at bucket 0 doesn't prove you found your song's length; it might be a squatter that hashed there first. So the table does exactly one kind of comparison — an equality check, == — to confirm the key it found is genuinely yours and not an impostor sharing the address. That's the only comparing a dict or set ever does, and on a healthy table it happens once, maybe twice. One hash, a jump, one ==: that is what "O(1)" is actually made of.

buckets.pypython
# a song's length IS its own hash — so its bucket is pure arithmetic
print(hash(200))          # 200   — small ints hash to themselves
print(200 % 8)            # 0     — "Blinding Lights" lands in bucket 0
print(248 % 8)            # 0     — "Get Lucky" wants that SAME bucket → collision
print(hash("Titanium"))   # a huge, salted number — different every run

Notice the last line. Integer keys hash to themselves, so their buckets are perfectly reproducible. String keys get a salted hash that changes every time Python starts, precisely so an attacker can't hand-craft keys that all collide. That's why the interactive below hashes song lengths (plain integers): the arithmetic is honest and you can check it by hand. Drag the slider and insert five songs one at a time, and watch two of them fight for a bucket.

Hash five song-lengths into 8 buckets — watch a collision probeinteractive
insert 4 of 5 — "One Dance", 174 s hash(174) = 174 174 & 7 = 6 (home bucket · same as 174 % 8) 0 1 2 3 4 5 6 7 collision — bucket 6 already holds Uptown (270 s) → probe to 7 home bucket = hash & 7 (the low 3 bits) — identical to hash % 8 whenever the table has a power-of-two number of slots
4 / 5
Same recipe every time: hash → % 8 → jump. A collision just means "take the next free slot" (a teaching model — the deeper cut shows how real Python actually chooses that slot). Lookup would replay the identical steps.
Fig 03b — Insert 1–3 land cleanly; insert 4 (One Dance) and insert 5 (Get Lucky) each collide and probe one slot onward. The gold outline is the blocked home bucket, the green outline is where the newcomer finally settles.
↺ reframe
A collision does not mean two keys are crammed into one box. It means one key found its first-choice box taken and politely walked to the next free one. Every slot still holds at most one key — the table is still a perfect index. All a collision costs you is a step or two of walking, and the guarantee that both writer and reader walk the same path is what keeps the index exact.
Wait — if collisions are guaranteed by counting, why not just build a giant table with millions of empty buckets, so keys almost never meet? You could — but you'd be spending real RAM to buy a speed you barely need, and a table that's mostly empty air also wastes the CPU's cache. Python instead keeps the table only comfortably loose — never more than about two-thirds full — which turns out to be the exact balance the next section is built around.

✗ The myth

A collision is a bug — a sign the hash function failed, something a well-written program should never allow to happen.

✓ The reality

Collisions are forced by pigeonhole counting and happen constantly in perfectly healthy tables. They're handled routinely, cost about one extra hop, and the table stays exact because every key follows the same probe route on the way in and the way out.

THE PATHOLOGY PYTHON GUARDS AGAINST
There is a nightmare: if every key hashed to the same bucket, probing would collapse into one long chain, and each lookup would walk it end to end — O(n), the very slowness a hash table exists to escape. It's measurable. A set of 2000 objects rigged to share one hash took roughly 80,000 ns per membership test, while a healthy 2000-element set answered in about 30 ns — some 2,600× faster, and the healthy time barely moved as the set grew. Python keeps the nightmare a pathology two ways: a strong, randomized hash so real keys scatter across buckets, and resizing the table before it ever crowds.
The deeper cut — "take the next free slot" is a teaching model, and how real Python probes

The +1-until-free picture in the interactive is a labelled simplification — right in spirit, wrong in mechanics, and we'll refine it here. Real CPython does not march to the neighbouring slot. When a bucket is taken, it mixes the still-unused high bits of the hash back into the index and jumps: perturb >>= 5; i = (i*5 + perturb + 1) & mask, starting from perturb = hash. For a key with hash 248 in an 8-slot table, that scatters the probe order to 0 → 1 → 6 → 7 → 4 → 5 → 2 rather than a tidy 0,1,2,3. Why bother? Linear probing lets collisions clump into long contiguous runs that swallow later insertions too — a snowball called primary clustering. Scattering the probes keeps chains short even when the table gets busy.

Two more truths hide under the visual. First, the home bucket is really hash & (size − 1), a bitmask. It only equals hash % size because CPython always sizes the table to a power of two (we checked: 248 & 7 and 248 % 8 are both 0). Second, deletion can't simply blank a slot: a key that probed past that slot would suddenly look unreachable, its chain broken. So CPython drops a tombstone — a "was-here, keep probing" marker — instead of a true empty. (Sets use a close cousin of this scheme: a short run of linear steps first, then the same perturbation.) Everywhere else in this chapter we'll keep saying "the next free slot," now that you know what's really underneath.

Probing is cheap while free slots are easy to find — but every insertion crowds the table, probes lengthen, and O(1) starts rotting toward O(n). Python refuses to let that happen: the moment the table drifts past about two-thirds full it quietly builds a bigger one. That trade — spend RAM, keep the speed — is the load factor. →

Wait — you don't need a crowded table to get collisions — you need shockingly few keys. This is the birthday paradox in disguise: 23 people share a birthday among 365 days at even odds, and the same math says dropping just ~1,177 random keys into a table of a million empty buckets already makes it 50/50 that two collide. Collisions aren't the table filling up — they're arithmetic, and they show up almost immediately.
THE ONE COMPARISON A DICT MAKES
A dict computes the bucket — it never scans — so the single question it asks on arrival is ==: "are you actually my key, or a squatter who hashed here first?" On a healthy table that check fires once, maybe twice, and it's done. One hash, one jump, one ==. That is the entire anatomy of O(1).
Wait — what happens when a key's home is the last bucket and it's taken? The probe doesn't fall off the end — it wraps back to bucket 0, treating the row as a ring: i = (i + 1) % 8. A key born in slot 7 can end up living in slot 0. As long as insertion and lookup circle the ring the same way, the wandering key is still found in a hop or two.
Trace3 step the machine — idea · code · memory move together
The collision probe — same route in, same route out
Two lengths hash to the same bucket. The newcomer probes to the next free slot; a later lookup retraces the identical hops, using == to tell its own key from a squatter — the single comparison a dict ever makes. (The plain "step to the next slot" shown here is a teaching model: real CPython perturbs the probe with the hash's high bits so collided keys scatter instead of clumping into one long chain — the section's deeper cut opens it up.) Press Next.
8 bucketslinear probehome = len & 7
The idea — one bucket row, a hopping probetable · 8 slotsinit
home = secs & 7  ·  probe → next free slot  ·  lookup: table[i] == key ?
In plain words
Under the hood
SETALU · the operation
Program · probe.py
variables · type
Memory · registers + object
registers — the running state
table list · 8 buckets — home = len & 7, None = free
what's happening
beat 1 / 1

04Load factor: RAM traded for speed

You've seen the bucket trick: hash(key) % size turns a key straight into a slot number, so a lookup is one jump instead of a search. But a fixed row of buckets has an obvious problem. Keep pouring keys in and it fills up, and the moment two keys want the same slot the dict has to probe onward to the next free one. The fuller the table, the longer those probes. So the dict watches one number like a hawk: the fraction of buckets in use, its load factor. Let the load climb too high and the crisp one-jump lookup rots into a long shuffle past occupied slots.

CPython's rule is blunt and effective: once about two-thirds of the buckets are spoken for, the dict allocates a fresh table twice as big and re-hashes every existing key into it. Bigger table, new % size, a new home computed for everyone. That rebuild is real work, O(n) in the number of keys, and the unlucky insert that triggers it pays the whole bill at once. But it happens rarely enough that, smeared across every insert, the cost stays flat — O(1) on average. It's the exact same amortized bargain you met with list append back in chapter 6: mostly-free operations, punctuated by an occasional pause to rebuild the world.

THE ONE TRADE
A dict hands the CPU an address it can compute instead of a shelf it must scan. The price of that computed jump is empty space — a table kept deliberately between one-third and two-thirds vacant. Speed, paid for in RAM. Every other property in this section is a footnote to that one bargain.

Sit with what that threshold implies: a dict is deliberately kept at least one-third empty, forever. The vacancy isn't slack it hasn't gotten around to using. It's the running room that keeps collisions rare and probes short. And it's why the dict is the heaviest container Python ships. On the machine I'm writing this on, building a one-million-entry dict reserves about 70 MiB — close to double the ~39 MiB a plain list of the very same numbers needs. Look at the bucket table alone, ignoring the numbers themselves, and it's 40.0 MiB against the list's 7.6 MiB pointer array, over five times heavier. A dict buys its speed with memory, and pays sticker price.

Crowded table → resize → sparse table, same keysfigure
8 slots, 6 used — load 75%: probes run long past ⅔ full → allocate a table 2× the size, then re-hash every key 16 slots, same 6 keys — load 37%: probes short again every empty square is RAM spent so a lookup stays one hop — the vacancy is the point
Fig 04 — Doubling doesn't add data; it adds room. Six keys that were elbowing each other in eight slots relax into sixteen, and the average probe drops back toward a single hop. The dict pays that RAM to keep the promise of one-jump lookup.
↺ the emptiness is the feature
You'd assume a good data structure is one that's packed tight and wastes nothing. A hash table is the opposite: it's fast because it refuses to fill up. The gaps aren't inefficiency the dict will eventually clean up — they are the mechanism. A full hash table is a slow hash table. Sparseness is the product you're buying.
Insert songs one by one — catch the moment the table doublesinteractive
slots 8 songs 5 load 62% ⅔ limit → resize table: 8 slots · growth so far 8
5
Watch the green bar climb toward the ⅔ line — then the table doubles and the bar snaps back to about a third. The real resize points are the 6th, 11th, and 22nd key: exactly where CPython grows an 8→16→32→64-slot table on this machine.
Fig 04b — Load never stays high. It sawtooths between roughly ⅓ and ⅔, because every time it nears the top the dict spends RAM to halve it again.
Wait — if the dict is kept a third empty on purpose, isn't that just wasted memory? It's the opposite of waste. Those empty slots are exactly what keep a lookup to a single hop — pack the table full and every search degrades into a long probe past occupied buckets. You're not paying for emptiness; you're paying for short probes, and the empty slots are the receipt.

✗ The myth

Adding a key to a dict always costs the same tiny amount — it fills up smoothly, one neat slot at a time.

✓ The reality

It lurches. Nearly every insert is a single write into a waiting slot — but a handful (the 6th key, the 11th, the 22nd…) stop dead to allocate a bigger table and re-hash everything already in it. Flat on average, spiky underneath. The average is a promise about the whole run, not about any one insert.
YOU CAN WATCH IT YOURSELF
sys.getsizeof reports a container's own footprint, so you can literally see the table jump the instant a resize fires — no profiler needed.
load_factor.pypython
import sys

d = {}
print(sys.getsizeof(d))          # 64  — empty, no bucket table allocated yet
for i in range(6):
    d[i] = i
print(sys.getsizeof(d))          # 352 — the 6th key forced an 8→16 slot table

# same keys keep arrival order — a side effect of the compact table (3.7+)
plist = {}
plist["Titanium"]        = 245
plist["Blinding Lights"] = 200
plist["Levels"]          = 203
print(list(plist))            # ['Titanium', 'Blinding Lights', 'Levels']

print(hash(200))              # 200            — an int hashes to itself, every run
print(hash("Levels"))         # a huge int that DIFFERS every run — strings are salted
The deeper cut — why an occasional O(n) rebuild is still O(1) on average

Doubling is the whole reason the average holds. Because each resize doubles the table, resizes get exponentially rarer as the dict grows: filling a dict to a million keys triggers only about nineteen rebuilds total — I counted them live — not a million. And the total copying done across every rebuild put together is 8 + 16 + 32 + … + ~2,000,000, a geometric series that sums to less than twice the final table. Twice the final size, spread over a million inserts, is a small constant per insert. That's the identical accounting trick behind list append in chapter 6. One honest label, though: this is amortized O(1), not worst-case O(1). A single unlucky insert really can freeze to move a million entries. You simply can't arrange for that to happen often, so the average stays flat even though one insert in a while is expensive.

The deeper cut — the machinery hiding under "% size"

CPython's table size is always a power of two, and that's not decoration — it makes the slot computation cheap. For a power-of-two size, hash % size is identical to hash & (size-1): one bitwise AND that masks off all but the low bits (chapter 0's AND, put to work), far cheaper than a division. (For a negative hash Python masks the raw bit pattern, so the arithmetic stays well-defined.) Probing isn't literally "try the next slot," either. The probe sequence folds the high bits of the hash back in — a "perturbed" walk — so keys that collide on their low bits scatter to different follow-up slots instead of clumping into one long chain.

Since 3.6 the dict is also compact: a small sparse array of slot numbers points into a dense entries array that stores key–value pairs in arrival order. That redesign cut dict memory by roughly 20–25%, and as a free side effect it made iteration follow insertion order — a language guarantee from 3.7 on, which is why Titanium, Blinding Lights, Levels come back in exactly that order above. String hashes are salted with a per-process random seed (SipHash), so an attacker can't precompute thousands of keys that all land in one bucket and stall your server. That's why hash("Levels") is a different giant number every run while hash(200) is always 200. And one last quirk you can trip over: hash() never returns -1. That value is reserved as an internal "error" flag, so an object whose hash works out to -1 quietly reports -2 instead.

Step back and the whole apparatus — the buckets, the load factor, the doubling, the re-hash — rests on a single unspoken promise: hash(key) must give the same answer at lookup time that it gave when the key was inserted. Move a key to a new bucket during a resize and later come back for it, and the only way to find it again is for its hash to be exactly what it was before. Break that promise and the key is simply lost in the table — present, but unfindable.

So the real question isn't how the table works — it's what kind of object can honestly make that promise. Why can a string or a number be a key, but a list never can? →

Wait — an empty dict has no buckets at all. {} weighs just 64 bytes and owns zero slots; the bucket table isn't born until the first key goes in (here it jumps to 224 bytes). A dict you declare and never fill costs almost nothing — you pay for the table only the instant you actually use it.
A MILLION INSERTS, NINETEEN PAUSES
Fill a dict to a million keys and nearly every insert is trivial — 999,981 of them just drop the key into a waiting slot, and only 19 ever stop to build a bigger table and re-hash everything. Each doubling makes the next one twice as rare, so the "expensive resize" you keep hearing about happens nineteen times in a million. That's why it averages to nothing.
Wait — you can watch the doubling happen, byte by byte. Call sys.getsizeof(d) as you add keys and it sits dead flat, then leaps: 224 → 352 → 632 → 1168 bytes, each jump landing on the exact key that tips the table past two-thirds full (the 6th, the 11th, the 22nd…). The staircase you're seeing is the resize — no profiler required.
Trace3 step the machine — idea · code · memory move together
Crossing two-thirds — the table that doubles itself
A dict watches its load factor. The moment used slots pass two-thirds of the table, it allocates one twice as big and re-hashes every key — a rare O(n) rebuild that keeps each cheap insert O(1) on average. Press Next and watch the meter climb, then snap back.
8 → 16 slotsload ≤ 2/3amortized O(1)
The idea — a load meter over a doubling bucket griddict · bucketsinit
load = used / slots
getsizeof64224352
2/3
In plain words
Under the hood
SETALU · the operation
Program · dict_resize.py
variables · type
Memory · registers + object
registers — the running state
d dict · bucket table + entries
what's happening
beat 1 / 1

05Why a list can't be a key

Every entry in a dict is filed by its key — but not by the key you see on the page. Python files it by the key's hash: a single number crunched out of the key's contents, and that number chooses a bucket. File this pair here. Now picture a key that can change its mind after you file it. You store ["Levels", "Avicii"] → 200; Python hashes the list, gets bucket 3, and drops the entry on that shelf. Later you rename a word inside the list. Its contents changed, so its hash changes with them. The next time you reach for it, Python hashes the new contents, computes bucket 6, opens an empty shelf, and swears the key was never there.

The 200 hasn't gone anywhere. It's still sitting in bucket 3. It has simply become unfindable — filed under an address that no longer exists. That is the quiet catastrophe Python refuses to allow: not lost data, but orphaned data, present and unreachable in the same breath.

So Python bans the failure mode before it can start. A dict key must be hashable: its hash must stay fixed for the object's entire life. For the built-in types that collapses to a single word — immutable (chapter 3's photographs, not its whiteboards). str, int, float, tuple, frozenset (plus bool and None) are frozen the moment they're made, so their hash can never drift, and they're welcome as keys. (A frozenset is a set welded permanently shut. You'll build both in the very next section; for now just read it as "an immutable collection," and therefore safe to file by.) list, dict and set can mutate in place, so they're turned away the instant you try. And the rule guards keys only: a value can be a list, a dict, a whole nested playlist. Values are cargo. Only keys are addresses.

THE ONE IDEA TO CARRY FORWARD
A key's hash cannot be allowed to move, because the hash is the filing address. Anything that can change its own contents can change its own hash — and would lose track of its own data. "Hashable" is just Python's word for "safe to file by."
Who may hold a key — and who is turned awayfigure
HASHABLE — hash frozen for life UNHASHABLE — contents can change ✓ welcome as a dict key str "Blinding Lights" int 200 float 3.5 tuple ("Titanium", 245) frozenset frozenset({"Sia"}) ✗ TypeError: unhashable type list ["Levels", "Avicii"] dict {"Sia": 245} set {"Sia", "Avicii"}
Fig 1 — The whole rule on one card: frozen types (left) may be keys because their hash never moves; the three mutable containers (right) are refused on sight. Notice a value like 245 or a whole list is fine on the right-hand side of the colon — the ban is for addresses, not cargo.
keys.pypython
# a dict key must be HASHABLE — its hash fixed for the object's whole life
lengths = { ("Blinding Lights", "The Weeknd"): 200 }   # tuple key — frozen, fine
lengths[("Blinding Lights", "The Weeknd")]              # 200 — same hash, found again

# a list can mutate, so Python refuses it the instant you try
{ ["Blinding Lights", "The Weeknd"]: 200 }
# TypeError: unhashable type: 'list'

# only KEYS are addresses — a VALUE can be anything, even a mutable list
plays = { "Blinding Lights": ["2019-11-29", "2020-03-20"] }
plays["Blinding Lights"].append("2026-07-10")          # fine — values face no rule
Hashable is contagious
A tuple is only hashable if everything inside it is. ("Titanium", 245) is frozen through and through, so it hashes. But ("The Weeknd", ["Blinding Lights"]) smuggles a list into slot two — one mutable part, and the whole tuple is refused: TypeError: unhashable type: 'list'. A container inherits its cargo's freedom, or lack of it.
Wait — a string is a key everywhere, yet I "edit" strings all day. How is that allowed? Because you never actually edit one. "Levels".replace("L", "R") doesn't touch the original — it builds a brand-new string "Revels" with its own hash and leaves "Levels" exactly as it was, frozen, still findable. The key you filed under never moved; the "edit" just minted a different object next to it. Same story for int and tuple: immutability is what makes them trustworthy addresses.

Below is the exact disaster the rule prevents — made visible. Pretend, just this once, that Python let a list be a key. It hashes to a bucket at insert time and the 200 is filed there. Now edit a word inside the list: its hash lands on a new bucket, and the arrow — the lookup — walks off to the wrong shelf. Slide it back to the original bucket and the two reunite. Anywhere else, the value is stranded.

Suppose a list could be a key — rename a word, watch 200 vanishinteractive
the hash chooses the bucket · edit the list and the hash — and the bucket — moves key = ["Levels", "Avicii"] hash(list) → bucket 3 0 1 2 3 4 5 6 7 · · · 200 the value · · · · lookup hashes to slot 3 → FOUND: 200
bucket 3
Slide to bucket 3 and the lookup lands on its own entry — found. Nudge it anywhere else and the shelf is empty: the 200 is still in slot 3, but the key no longer points there. Python forbids list keys precisely so this arrow can never move.
Fig 2 — A key that can change its hash walks away from its own data. Every non-3 bucket is empty, so the lookup fails while the value sits untouched — the exact "orphaned in RAM" failure the hashable rule outlaws.
↺ reframe
A key isn't the data — it's the address of the data. A dict is a filing system that computes where to look from the key itself, no searching. Now the ban is obvious: an address that can quietly rewrite itself is a parcel that loses its own house. Python doesn't forbid mutable keys to be strict; it forbids them so that every lookup you ever write is guaranteed to land where the value actually is.

✗ The myth

"Lists are banned as keys because Python is being fussy about types — a needless restriction I have to work around with tuples."

✓ The reality

The ban is the one thing that keeps a dict correct. Allow a key whose hash can move and you don't get a slightly-slower dict — you get one that silently misplaces entries. Wrapping in a tuple isn't a workaround; it's you promising "this address will never change," which is exactly the promise a key must make.

The deeper cut — "bucket = hash mod 8" is a useful simplification; here's the real machinery

Saying the hash "chooses a bucket" is a labelled simplification — true enough to reason with, and worth refining once. A real CPython dict starts as a table of 8 slots and picks the bucket from the low bits of the hash: hash & (size − 1), which for an 8-slot table is hash & 7, the last three bits. So two very different keys can share a bucket — hash(200) & 7 and hash(208) & 7 are both 0 — and the table just probes onward to the next slot. The picture, hash names a shelf, is exactly right; the arithmetic is masking, not schoolbook modulo.

Which makes the mutable-key bug even nastier than "always a miss." Whether the renamed key lands on an empty shelf (a clean KeyError) or happens to collide back onto its original slot (where CPython's identity shortcut finds it again) depends on the exact bits. And for strings those bits are salted with a per-process random seed (PYTHONHASHSEED). So the same mutation can fail on Tuesday and quietly succeed on Wednesday. Corruption you can't even reliably reproduce is the strongest possible argument for outlawing it at the door rather than trusting yourself to be careful. The snippet below hand-builds a lying key to make the failure concrete. Don't worry about the class syntax (that's chapter 12): read __hash__ as "the method that computes this object's hash" and __eq__ as "the method that decides whether two are equal." Here the hash is deliberately wired to a mutable field n, so changing n silently moves the object's bucket out from under it:

orphan.pypython
class Key:                              # pretend a mutable object were an allowed key
    def __init__(self, n): self.n = n
    def __hash__(self):    return self.n   # hash comes FROM the contents
    def __eq__(self, o):  return self.n == o.n

k = Key(0)
d = { k: "Avicii", Key(1): ..., Key(2): ..., Key(4): ..., Key(5): ... }
k in d        # True  — filed in slot 0
k.n = 3       # mutate: the hash now points at slot 3
k in d        # False — slot 3 is empty; the "Avicii" is orphaned in slot 0

So a dict is really a wall of hashed buckets, each holding a key with a value riding shotgun. Rip the values out and keep only the hashed keys, and you haven't broken anything — you've built Python's other hash structure →

IDENTITY IS THE FALLBACK ADDRESS
A forward glance — you'll build your own object types (classes) from the metal up in chapter 12; here is the one line that will matter. When Python can't freeze an object's contents into a hash, it falls back to the object's id() — its raw address in memory, chapter 2's identity. So a custom object is hashable by default, filed by where it lives rather than what it holds: two objects with identical contents are still different keys, because they sit at different addresses. The moment you teach two of them to count as equal by contents, you owe the dict a matching hash that never moves — the exact promise every key must keep. File it away; it lands fully in chapter 12.
Wait — None, True and False can all be dict keys. The rule is "hashable = immutable," and those three are frozen singletons, so {True: "yes", None: "maybe", False: "no"} is perfectly legal. It surprises people only because we file them under "values you'd never look something up by" — but to the dict they're just three more frozen addresses.
↺ freezing is signing a promise
Wrapping a list in a tuple to use it as a key doesn't make the data "more constant" — the same words are still in there. What changes is that you have signed a promise: "I will never rewrite this." tuple(["Blinding Lights", "The Weeknd"]) as a key is your signature, and hash() is the notary. Python doesn't ban mutable keys to be strict; it bans them because a promise nobody can keep is worse than no promise at all.

06A set is a dict with the values ripped out

You just built a dict: a hash table that jumps straight to a key's value in a single step. Now do something almost violent to it — grab the whole value column and rip it clean off. What's left is still a hash table, still jumping straight to a key in one hop, but the keys have nothing left to point at. They just are, or aren't, present. That stripped-down table is a set, and throwing the values away buys you two gifts you never asked for.

First, every element is automatically unique. Add "Titanium" to a set that already holds it and the newcomer hashes to the exact bucket the old one occupies, so the table shrugs and keeps one. Duplicates cannot survive, ever, without you writing a line to stop them. Second, the question a set answers fastest is membership: x in s is the same one-hop hash jump a dict lookup is — O(1), whether the set holds ten songs or ten million. Ask a list the same thing and it walks every element from the front, comparing as it goes — O(n), a scan that slows down in lockstep with your data.

One thing you surrender: order. A set has no positions, no index, no "first" element — songs[0] raises TypeError: 'set' object is not subscriptable. Iterate one and elements emerge in an order dictated by their hashes, not by when you added them. A set answers a single question superbly — is this in here? — and politely declines the rest.

The one idea to carry forward
A set is a dict with the value column deleted. Everything else is a consequence: keys-only means duplicates can't coexist, and the hash table underneath means membership is a jump, not a search.
Delete the values — a set is what remainsfigure
dict · keys + values "Titanium" 245 "Levels" 203 "Faded" 212 key column value column ✂ rip out the values the keys survive untouched set · keys only "Titanium" "Levels" "Faded" "Titanium" added again — same bucket, dropped same hash table, value column gone — the keys, and only the keys, remain
Fig 6 — Delete a dict's value column and a set is what's left: the same one-jump hash table, now storing membership alone. A key that's already present hashes to a full bucket and is quietly discarded — uniqueness with no code to enforce it.

That O(1)-versus-O(n) gap sounds abstract until you watch it open. Picture your whole library as N songs and ask one question: is "Titanium" in it? A list has to be ready to check all N. A set checks exactly one bucket, no matter how large N grows. Drag N and watch the two costs pull apart.

Membership cost — one question, two structuresinteractive
is "Titanium" among N songs? (worst case — it isn't there) LIST — x in list, checked one by one ≤ 250 checks SET — x in set, one hash jump 1 check 250 songs · list checks up to 250 · set checks 1 · ~250× fewer the set never scans — it computes where "Titanium" would sit and looks only there
250
Slide N. The red bar is how far a list might have to walk; the green nub never moves. That single set "check" isn't literally zero work — it's the §3 anatomy: one hash(), one jump to the computed bucket, one == to confirm the key is really yours. Fixed cost, no matter how large N grows. Membership is the set's whole reason to exist.
Fig 6b — The list's cost climbs with N; the set's stays pinned at one. Measured on this machine with 100,000 songs, the absent-item check took about 580 µs in a list versus 0.03 µs in a set — roughly seventeen-thousandfold.

In code the whole personality of a set fits in a few lines. Notice the third "Titanium" never lands, that discard forgives a missing element while remove does not, and that frozenset makes an immutable twin:

sets.pypython
# bare braces, no colons -> a set; the duplicate never lands
songs = {"Titanium", "Levels", "Titanium"}
print(len(songs))            # 2
print("Levels" in songs)     # True  -> one hash jump, O(1)

songs.add("Faded")
songs.discard("Ghost")       # not there? no complaint
# songs.remove("Ghost")      -> KeyError: 'Ghost'

locked = frozenset(songs)    # immutable, and therefore hashable
{} is NOT an empty set
{} builds an empty dict — dicts claimed the braces first. The empty set is written set(). Non-empty braces are unambiguous: values with colons make a dict, bare values make a set. (You can prove it: type({}) is dict, type(set()) is set.)

Because a set is pure membership, it does the algebra you once drew as overlapping circles — and it's genuinely useful. Say you and a friend each keep a set of liked songs. One operator apiece answers a real question:

algebra.pypython
mine  = {"Blinding Lights", "Titanium", "Levels", "Wake Me Up"}
yours = {"Titanium", "Levels", "Sunflower", "Faded"}

mine & yours   # {'Titanium', 'Levels'}          -> songs you both like
mine | yours   # all six titles, no repeats      -> everything, deduped
mine - yours   # {'Blinding Lights', 'Wake Me Up'} -> only in mine
mine ^ yours   # the four neither shares         -> symmetric difference

{"Titanium", "Levels"} <= mine   # True -> subset test

Each returns a brand-new set and leaves the originals alone (there's isdisjoint and the <=/>= subset tests too). This is the fastest way in the language to answer "what's shared?", "what's the total, once?", or "what's unique to one side?".

Two lists → one dict, in a line
dict(zip(titles, seconds)) pairs them up: keys from the first list, values from the second. The zip rules still hold — it truncates to the shorter list, and if a title repeats, the last one wins.
pair_up.pypython
titles  = ["Blinding Lights", "Titanium", "Levels"]
seconds = [200, 245, 203]

lengths = dict(zip(titles, seconds))
# {'Blinding Lights': 200, 'Titanium': 245, 'Levels': 203}

for title, secs in lengths.items():
    print(title, "→", secs)
Wait — if a set stores its elements by hashing them, what can't go in one? Anything unhashable — anything that can change out from under it. Try {[1, 2]} and Python refuses: TypeError: unhashable type: 'list'. A list's contents can shift, so its hash would shift too, and the element would end up lost in the wrong bucket. That single rule is the whole reason frozenset exists: freeze a set and it becomes immutable, therefore hashable, therefore legal as a dict key or as an element inside another set.
↻ uniqueness was never a feature
Nobody coded a "reject duplicates" rule into sets. Uniqueness falls out of the hash table for free: a duplicate simply hashes to a bucket that's already taken, so there's nowhere new to put it. The dedupe loop you'd write by hand is just… the physics of the structure. That's why set(my_list) is the one-liner everyone reaches for to strip repeats.

✗ The myth

Sets are "unchangeable" — a claim copy-pasted across half the tutorials online.

✓ The reality

Sets are fully mutable: add, update, discard, clear. The immutable one is frozenset — and because it can't change, its hash is stable, so it alone can be a dict key or live inside another set.
The deeper cut — why "a set IS a dict" is a useful lie

"A set is a dict with the values ripped out" is a labelled simplification — exactly true for how you should reason about sets, not quite true about the metal, and worth refining once. In CPython a set is not literally a dict carrying None in every slot. It's its own C type in setobject.c, with a probe sequence tuned specifically for the one thing sets do — answer membership — rather than the key-to-value mapping a dict optimises for. You can even see they're different structures: on this machine an empty set() weighs 216 bytes while an empty {} weighs just 64, because a set pre-allocates a wider table up front. So the shared machinery is real — both hash their keys, both use open addressing, both give you O(1) membership — and that's precisely why the "dict minus values" picture predicts a set's behaviour flawlessly. The only word doing the lying is "is". Keep the picture; just know that under the hood the set is its own animal, bred for a single question.

You now hold four containers — list, tuple, dict, set — each blazing at one job and clumsy at another. The next chapter lines them up in one ring: memory cost and Big-O side by side, the specialists (array, deque) that beat them in a pinch, and it opens with the decision map itself — five questions that pick your structure before you type a bracket. →

Wait — a set and a dict are the same hash table, but they keep opposite habits about order. A dict remembers the order you filled it (a guarantee since 3.7). A set does not: add "Titanium", "Blinding Lights", "Levels" and they come back Titanium, Levels, Blinding Lights — shuffled into hash order, not arrival order. Ripping out the value column threw away the bookkeeping that preserved sequence, too.
Wait — a set is mutable, yet everything inside it must be frozen. You can add and discard all day, but try {[1, 2]} and Python refuses: unhashable type: 'list'. A set files each element by its hash, so the box can change while its contents cannot. Want a collection inside a set? It needs a passport — {frozenset({1, 2})} is legal, {{1, 2}} is not.
OVERLAPPING CIRCLES, ONE KEYSTROKE EACH
Because a set is pure membership, it does the Venn-diagram algebra you drew in school — one operator per question. With mine and yours as liked-song sets: mine & yours is what you both love, mine | yours is everything deduped, mine - yours is only-mine, mine ^ yours is what neither shares, and {"Titanium", "Levels"} <= mine asks "is this a subset?" Each returns a brand-new set and leaves the originals untouched.
Trace3 step the machine — idea · code · memory move together
Dedupe for free — the duplicate that can't land
A set is a dict with the value column ripped out. Adding a key that already exists hashes to an occupied bucket, meets the equal key there, and is silently dropped — uniqueness with no code to enforce it, while membership stays a one‑hop jump. Press Next and watch the duplicate bounce off.
8 bucketskeys · no valuesadd / in · O(1)
Keys-only hash table — the value column, torn offset · 8 bucketsnew
#keyvalue · torn away
len 0
In plain words
Under the hood
NEWALU · the operation
Program · dedupe.py
variables · type
Memory · buckets + count
registers — the running state
songs · buckets keys only — value column torn off
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 07, in working code

A dict is a hash table wearing friendly clothes: it turns a key into a memory slot so lookup costs the same whether you hold ten songs or ten million. Here we build them, read them, mutate them, count with them, and meet their unkeyed sibling, the set.

Build & look up
A dict maps keys to values. Bracket-access finds a value in one hop; .get lets a miss return a default instead of crashing.
Views & iteration
A dict lends out live views of its keys, values, and pairs. Loop over them directly, or reshape them with a comprehension.
Mutating & merging
Dicts change in place. update merges another dict; setdefault inserts only when a key is missing — perfect for grouping.
Counting
The classic dict move: tally occurrences. Do it by hand with .get, then let collections.Counter do it in one line.
Sets
A set is a dict with keys but no values: unordered, no duplicates, O(1) membership. Its algebra — union, intersection, difference — is where it shines.
end of chapter 07 · six sections
iolinked.com
Written by Ajai Raj