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.
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.
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 fetchTwo 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.
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.
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.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. →
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.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.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."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 search — start + 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.
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.# 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 0–7, 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.
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.
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? →
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 == 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.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."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."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.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.
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.
"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.
# 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 runNotice 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.
✗ 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 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. →
==: "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).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.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.
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.
✗ 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.sys.getsizeof reports a container's own footprint, so you can literally see the table jump the instant a resize fires — no profiler needed.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 saltedThe 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? →
{} 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.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.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.
245 or a whole list is fine on the right-hand side of the colon — the ban is for addresses, not cargo.# 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("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."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.
200 is still in slot 3, but the key no longer points there. Python forbids list keys precisely so this arrow can never move.✗ 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:
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 0So 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 →
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.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.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.
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.
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.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:
# 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{} 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:
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 testEach 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?".
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.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){[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.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. →
"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.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.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.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.