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 no move left but to walk the whole row asking is it you? is it you? That's a million questions for a library of a million tracks. In this chapter we kill that walk, and I want to take it slowly. There's a genuinely beautiful trick hiding in here that most tutorials wave straight past. Here's the plan. We hand every key to hash() and watch it collapse into a single number. We let that number pick 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. All the way through we keep circling the one question that actually 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 to find a set was hiding inside it the whole time. No magic anywhere in 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, because 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, holding each pair up to the light and asking the same dull question — is it you? is it you? is it you? The pair you want could be sitting anywhere. In the worst case Python 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 all over 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: a colon glues each key to its value, and commas separate the pairs. Since Python 3.7 a dict even 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). It's the same tiny price whether the library holds ten songs or ten million. And you have 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, mapping 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.
dict(titanium=245) is the keyword form. Tidy, but the keys arrive as strings and must be legal names, so a title with a space is impossible.song["artist"] uses the same square bracket a list does — but what goes inside is a name, not a position..get is the same lookup with softer manners: present → the value, absent → None, or whatever default you name."genre" in song asks about a key. To ask about a value you have to say so: 200 in song.values().you type
song = {"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200}
print(song["artist"])
print(len(song))
print("genre" in song)
print(song.get("genre"))
print(song.get("genre", "unknown"))
lengths = dict(titanium=245, levels=203)
print(lengths)
print(lengths["titanium"])
empty = {}
print(empty, len(empty), type(empty))
try:
print(song["genre"])
except KeyError as e:
print("KeyError:", e)you see
The Weeknd
3
False
None
unknown
{'titanium': 245, 'levels': 203}
245
{} 0 <class 'dict'>
KeyError: 'genre'song["genre"]on a missing key does not hand back a blank — it raisesKeyError: 'genre'and stops the program dead..get(key)with no default givesNone, not0and not"". ThenNone + 1raisesTypeErrora line later: the crash moved, it did not vanish.{}is an empty dict, never an empty set. Braces belong to dicts first; the empty set isset().inlooks at keys only."The Weeknd" in songis False even though the artist is sitting right there in a value.- Keys are unique, and Python will not warn you. Write
{"a": 1, "a": 2}and the last one silently wins:{'a': 2}.
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 can occasionally be already taken by a different key — a collision. Then the dict has to probe a couple of nearby slots to sort it out, so 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 rare 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. 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. →
>>> from timeit import timeit
>>> lst = list(range(1_000_000))
>>> d = {n: n for n in range(1_000_000)} # a million keys, n -> n
>>> timeit("999_999 in lst", globals=globals(), number=100) # 100 list scans
>>> timeit("999_999 in d", globals=globals(), number=100) # 100 dict lookups0.7066838000027929 4.1000021155923605e-06
999_999 — the last box — a hundred times and burned 0.7 seconds; the dict answered all hundred asks in four millionths of a second, about 41 ns each, because it never searches: it computes the key's bucket and jumps, at the same tiny price for ten keys or ten million.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, because 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(). And 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. 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. It is there that the key-pointer, the value-pointer, and a cached copy of the hash live side by side, and 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.
library["Faded"] = 212 inserts; library["Titanium"] = 246 overwrites. Python never asks which you meant..update merges. New keys land at the end; a key already present keeps its position and just changes its value.del is chapter 3's statement, the one that unbinds a name. Here it unfiles exactly one pair and returns nothing..pop is the only remover that hands the value back — the sole reason a name like dropped has anything in it..setdefault(key, []) is the grouping trick: plant an empty list on first sighting, then get that same list back forever after.you type
lengths = {"Blinding Lights": 200, "Titanium": 245}
lengths["Levels"] = 203 # new key -> a new entry, appended
lengths["Titanium"] = 246 # key already there -> value replaced
print(lengths)
lengths.update({"Faded": 212, "Levels": 202})
print(lengths)
del lengths["Blinding Lights"]
print(lengths)
gone = lengths.pop("Faded")
print(gone, lengths)
print(lengths.pop("Nightcall", 0))
plays = {}
plays.setdefault("Levels", []).append("mon")
plays.setdefault("Levels", []).append("tue")
print(plays)you see
{'Blinding Lights': 200, 'Titanium': 246, 'Levels': 203}
{'Blinding Lights': 200, 'Titanium': 246, 'Levels': 202, 'Faded': 212}
{'Titanium': 246, 'Levels': 202, 'Faded': 212}
212 {'Titanium': 246, 'Levels': 202}
0
{'Levels': ['mon', 'tue']}- Assignment never asks permission. A typo does not error, it creates a second entry:
library["Titamium"] = 245quietly files a song nobody will ever find. delreturns nothing at all, and it is a statement:gone = del d[k]is aSyntaxError. When you want the value, reach for.pop..updateoverwrites in silence. Merging{"Levels": 202}into a dict that already knowsLevelsthrows the old number away without a word..setdefaultreturns the value, not the dict. That is whyd.setdefault(k, []).append(x)works — and whyd = d.setdefault(...)destroys your dict.- Every move here mutates in place. Chapter 3's aliasing rule still holds: two names on one dict both see every change.
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: true in its result, tidier than the truth. A dict's table is always sized to a power of two, so 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. Every lookup would collapse 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 is 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 a hash table that sometimes lost your data.
for title in lengths: walks keys, because the key is what you look things up by. Reach for the value inside the loop with lengths[title]..items() yields one tuple per pass, and for k, v in … is chapter 3's unpacking doing its job again..keys(), .values() and .items() are views, not copies. Change the dict and the view changes underneath you.sorted(d) sorts the keys; sorted(d.items()) sorts the pairs by key. Neither one touches the dict itself.you type
lengths = {"Titanium": 245, "Blinding Lights": 200, "Levels": 203}
for title in lengths:
print(title)
for title, secs in lengths.items():
print(title, "->", secs)
print(list(lengths.keys()))
print(list(lengths.values()))
print(sum(lengths.values()), "seconds in", len(lengths), "songs")
for title in sorted(lengths):
print(title, lengths[title])you see
Titanium
Blinding Lights
Levels
Titanium -> 245
Blinding Lights -> 200
Levels -> 203
['Titanium', 'Blinding Lights', 'Levels']
[245, 200, 203]
648 seconds in 3 songs
Blinding Lights 200
Levels 203
Titanium 245for k, v in lengths:— forgetting.items()— raisesValueError: too many values to unpack (expected 2). The bare loop gives one key, not a pair.- Insertion order is guaranteed, but it is not sorted order. Print a dict and the songs come back in arrival order, whatever the alphabet thinks.
- Never add or delete keys while looping one:
RuntimeError: dictionary changed size during iteration. Loop overlist(d)when you must edit. - A view is not a list.
lengths.keys()[0]raisesTypeError: 'dict_keys' object is not subscriptable— there are no positions to index. - Chapter 5's
enumeratestill works, but it numbers your walk, not the dict. A dict has no positions of its own to hand out.
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. And the payoff is that lookup, later, retraces the identical steps. It hashes the key, jumps to the home bucket, asks "is this my key?", and if not, steps 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.
d.get(k, 0) fetches the count so far — or 0 for a letter never seen — then + 1, then file it back.0 is doing the real work. It answers the only awkward question: what was the count before I had ever seen this key?setdefault takes over when the value is a container: plant [] once, then append to that same list forever after.collections.Counter, which is this line in a box.you type
counts = {}
for letter in "LEVELS":
counts[letter] = counts.get(letter, 0) + 1
print(counts)
genres = {}
for song in ["edm", "pop", "edm", "rap", "edm"]:
genres.setdefault(song, 0)
genres[song] += 1
print(genres)
plays = {}
for title in ["Levels", "Titanium", "Levels"]:
plays.setdefault(title, []).append("played")
print(plays)
broken = {}
try:
broken["L"] += 1
except KeyError as e:
print("KeyError:", e)you see
{'L': 2, 'E': 2, 'V': 1, 'S': 1}
{'edm': 3, 'pop': 1, 'rap': 1}
{'Levels': ['played', 'played'], 'Titanium': ['played']}
KeyError: 'L'd[k] += 1on a fresh key raisesKeyError.+=reads before it writes, and it is the read that fails.d.get(k)without the0hands backNone, andNone + 1is aTypeError. The default is not decoration here; it is the idea..setdefault(k, [])builds a fresh empty list on every single call and throws it away when the key already exists. Correct, mildly wasteful, used everywhere.- Do not hand out a shared default:
d.setdefault(k, bucket)gives every missing key the same list object. Write the[]inline. - Counting is not ranking. The dict keeps arrival order; sorting by count is a separate move, and it needs the pairs, not the keys.
"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 dead reproducible. But 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 exactly 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 hiding 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.
plays = {"Levels": 3, "Titanium": 1}, song = {"title": "Blinding Lights", "artist": "The Weeknd"} and library = {"Levels": 203, "Titanium": 245}. Now run each of these and read the traceback out loud: plays["Faded"] += 1, then print(song["genre"]), then del library["Nightcall"]. Each raises KeyError, and the quoted name after the colon is the key Python could not find — that is the whole message. Your job is to fix all three without wrapping anything in try. Ask what each line actually wants. The first wants a starting count when the song is new. The second wants a stand-in when the field was never filled in. The third wants a delete that shrugs at a song that was never there. Python has one small tool for each. Then ask yourself the sharper question: which of the three should still crash in a real program — because a key that is missing means your data is wrong, and you would rather know?show the solution
plays = {"Levels": 3, "Titanium": 1}
song = {"title": "Blinding Lights", "artist": "The Weeknd"}
library = {"Levels": 203, "Titanium": 245}
# ---- the three crashes, caught here so you can read them side by side ----
try:
plays["Faded"] += 1
except KeyError as e:
print("crash 1 KeyError:", e)
try:
print(song["genre"])
except KeyError as e:
print("crash 2 KeyError:", e)
try:
del library["Nightcall"]
except KeyError as e:
print("crash 3 KeyError:", e)
# ---- fix 1: get() supplies the missing starting value ----
plays["Faded"] = plays.get("Faded", 0) + 1
print("fix 1 ", plays)
# ---- fix 2: get() with a fallback, or an `in` guard when you need a branch ----
print("fix 2a ", song.get("genre", "unknown"))
if "genre" in song:
print("fix 2b genre is", song["genre"])
else:
print("fix 2b no genre on this track")
# ---- fix 3: pop() with a default deletes if present and shrugs if not ----
library.pop("Nightcall", None)
print("fix 3 ", library)
# the same counting fix, written with setdefault
plays.setdefault("Wake Me Up", 0)
plays["Wake Me Up"] += 1
print("bonus ", plays)
# ------------- what it prints -------------
crash 1 KeyError: 'Faded'
crash 2 KeyError: 'genre'
crash 3 KeyError: 'Nightcall'
fix 1 {'Levels': 3, 'Titanium': 1, 'Faded': 1}
fix 2a unknown
fix 2b no genre on this track
fix 3 {'Levels': 203, 'Titanium': 245}
bonus {'Levels': 3, 'Titanium': 1, 'Faded': 1, 'Wake Me Up': 1}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.
# letter_frequency.py -- the counting idiom, run over one song title
title = "BLINDING LIGHTS"
counts = {}
for letter in title:
if letter == " ":
continue
counts[letter] = counts.get(letter, 0) + 1
print("title :", title)
print("counted :", sum(counts.values()), "letters,", len(counts), "distinct")
print()
print("first-seen order -- a dict remembers arrival")
for letter, n in counts.items():
print(" ", letter, "x", n)
print()
print("ranked by count")
ranked = []
for letter, n in counts.items():
ranked.append((-n, letter)) # negate the count so a plain sort ranks it
ranked.sort()
for neg_n, letter in ranked:
print(" ", letter, "|" + "#" * -neg_n, -neg_n)
print()
repeated = []
for letter, n in counts.items():
if n > 1:
repeated.append(letter)
print("repeated:", sorted(repeated))
print("I count :", counts.get("I", 0))
print("Z count :", counts.get("Z", 0))
title : BLINDING LIGHTS counted : 14 letters, 9 distinct first-seen order -- a dict remembers arrival B x 1 L x 2 I x 3 N x 2 D x 1 G x 2 H x 1 T x 1 S x 1 ranked by count I |### 3 G |## 2 L |## 2 N |## 2 B |# 1 D |# 1 H |# 1 S |# 1 T |# 1 repeated: ['G', 'I', 'L', 'N'] I count : 3 Z count : 0
letter_frequency.py and run it from the terminal. The whole program lives in one line: counts[letter] = counts.get(letter, 0) + 1. On the first B there is no entry, so .get supplies the 0, the + 1 makes it 1, and the assignment files it. On the second L the same line reads 1 and writes 2. One line, both cases, no if anywhere. Now read the two orderings, because they are the lesson. The first list comes out B, L, I, N, D… — the order the letters first arrived, which is what a dict remembers since Python 3.7. The second is ranked, and getting there took a small honest trick: we built (-n, letter) tuples and called plain .sort(). Negating the count makes the biggest count sort first, and ties then fall alphabetically — which is why B, D, H, S, T line up neatly at the bottom. Two things to try. Change title to your own name and watch the counts follow. Then delete the , 0 from .get(letter, 0) and read the TypeError: it is None + 1, and it tells you exactly how much work that little zero was doing.# track_lookup.py -- one library dict, six moves, the state printed after each
library = {"Blinding Lights": 200, "Titanium": 245, "Levels": 203}
print("start ", library)
library["Faded"] = 212 # new key -> a new entry at the end
print("library['Faded'] = 212 ", library)
library["Titanium"] = 246 # key already here -> value replaced
print("library['Titanium'] = 246", library)
library.update({"One Dance": 174, "Levels": 202})
print("update(2 songs) ", library)
dropped = library.pop("Blinding Lights") # pop removes AND hands the value back
print("pop('Blinding Lights') ", library)
print(" it handed back ", dropped)
del library["Faded"] # del is a statement -- nothing returns
print("del library['Faded'] ", library)
print()
print("how long is Levels? ", library.get("Levels", 0), "s")
print("how long is Nightcall? ", library.get("Nightcall", 0), "s <- absent, no crash")
print("is Faded still in there? ", "Faded" in library)
print("songs ", len(library))
print("total playtime ", sum(library.values()), "s")
print()
print("alphabetical running order")
for title in sorted(library):
secs = library[title]
print(f" {title:<10} {secs // 60}:{secs % 60:02d}")
start {'Blinding Lights': 200, 'Titanium': 245, 'Levels': 203}
library['Faded'] = 212 {'Blinding Lights': 200, 'Titanium': 245, 'Levels': 203, 'Faded': 212}
library['Titanium'] = 246 {'Blinding Lights': 200, 'Titanium': 246, 'Levels': 203, 'Faded': 212}
update(2 songs) {'Blinding Lights': 200, 'Titanium': 246, 'Levels': 202, 'Faded': 212, 'One Dance': 174}
pop('Blinding Lights') {'Titanium': 246, 'Levels': 202, 'Faded': 212, 'One Dance': 174}
it handed back 200
del library['Faded'] {'Titanium': 246, 'Levels': 202, 'One Dance': 174}
how long is Levels? 202 s
how long is Nightcall? 0 s <- absent, no crash
is Faded still in there? False
songs 3
total playtime 622 s
alphabetical running order
Levels 3:22
One Dance 2:54
Titanium 4:06
track_lookup.py and run it. There is no cleverness here on purpose: every line does one thing to one dict, then shows you the dict. Read the output, not the code. library['Faded'] = 212 adds a fourth pair at the end; the identical-looking library['Titanium'] = 246 adds nothing and replaces a value — same syntax, opposite effect, and only the key decides which. Watch update next: One Dance is new so it joins the end, while Levels was already there, so it keeps its old position and only its number changes. Then the removals split cleanly. pop hands the value back, which is the only reason dropped holds 200; del hands back nothing at all, so there is no name to catch. The tail is the part worth typing twice. get("Nightcall", 0) returns 0 instead of crashing, and sorted(library) gives the titles alphabetically without touching the dict's own arrival order. Two things to try. Swap get("Nightcall", 0) for library["Nightcall"] and read the KeyError. Then move the del line above the pop line and watch every printed dict after it shift — proof that you are editing one object in place, not making copies.✗ 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%. As a free side effect, it made iteration follow insertion order — a language guarantee from 3.7 on. That's 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.
lengths = {"Blinding Lights": 200, "Titanium": 245, "Levels": 203, "Faded": 212}. Write a program that builds by_length, the same data filed the other way round: the seconds become the keys and the titles become the values, so that by_length[245] hands you 'Titanium'. One for over .items() and one assignment will do it. Then break your own program on purpose. Add lengths["One Dance"] = 200 — a second song of exactly 200 seconds — and run it again. Keys must be unique, so one of those two titles is now gone, and Python said nothing. Work out which one survives and why, before you fix it. The fix is the grouping shape from the counting box: let each length hold a list of titles instead of one title. Finally, ask the honest question about the inversion itself: it only works at all because every value in lengths is a number, and numbers are hashable. Try inverting a dict whose values are lists and read the TypeError — it is the same rule section 05 is about to spend a whole page on.show the solution
lengths = {"Blinding Lights": 200, "Titanium": 245, "Levels": 203, "Faded": 212}
# 1 -- THE STRAIGHT FLIP: every value becomes a key, every key becomes a value
by_length = {}
for title, secs in lengths.items():
by_length[secs] = title
print(by_length)
print(by_length[245])
# 2 -- WHAT IT SILENTLY EATS: two songs can share a length, keys cannot
lengths["One Dance"] = 200 # same 200 seconds as Blinding Lights
naive = {}
for title, secs in lengths.items():
naive[secs] = title
print(naive[200]) # only the LAST title survives -- the first is gone
# 3 -- THE HONEST FLIP: each length keeps a LIST of the titles that share it
by_length = {}
for title, secs in lengths.items():
by_length.setdefault(secs, []).append(title)
print(by_length)
for secs in sorted(by_length):
print(secs, "->", by_length[secs])
# ------------- what it prints -------------
{200: 'Blinding Lights', 245: 'Titanium', 203: 'Levels', 212: 'Faded'}
Titanium
One Dance
{200: ['Blinding Lights', 'One Dance'], 245: ['Titanium'], 203: ['Levels'], 212: ['Faded']}
200 -> ['Blinding Lights', 'One Dance']
203 -> ['Levels']
212 -> ['Faded']
245 -> ['Titanium']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. 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.
("Titanium", "David Guetta") keys a song by title and artist — two facts, one address.frozenset is a set that cannot change, so it hashes, so it may be a key or live inside another set. Same members, same key, whatever order you write them.1, 1.0 and True are equal and hash alike, so d[1] and d[True] are the same entry.you type
print(hash("Titanium") == hash("Titanium"))
print(hash(245), hash(2.0), hash(True))
by_pair = {("Titanium", "David Guetta"): 245}
print(by_pair[("Titanium", "David Guetta")])
groups = {frozenset({"Levels", "Faded"}): "edm night"}
print(groups[frozenset({"Faded", "Levels"})])
d = {1: "one"}
d[True] = "same key"
print(d, len(d))
try:
{["Titanium", "David Guetta"]: 245}
except TypeError as e:
print("TypeError:", e)
try:
hash({"artist": "The Weeknd"})
except TypeError as e:
print("TypeError:", e)
try:
set().add({"Levels", "Faded"})
except TypeError as e:
print("TypeError:", e)you see
True
245 2 1
245
edm night
{1: 'same key'} 1
TypeError: unhashable type: 'list'
TypeError: unhashable type: 'dict'
TypeError: unhashable type: 'set'- The message names the type, not your variable:
TypeError: unhashable type: 'list'. Read the quoted type and you know instantly which argument Python refused. - A tuple is hashable only if everything inside it is.
("Titanium", ["a"])raises the list'sTypeError, because hashing a tuple hashes its contents. d[1]andd[True]are the same key — and{200: "a", 200.0: "b"}collapses to one entry. Equality decides identity, not the type you typed.- Set elements obey exactly the same rule, because a set is a dict's key column:
{[1, 2]}raises the identicalTypeError. - The repair is one call —
tuple(my_list)orfrozenset(my_set). You are not converting data; you are signing a promise never to change it.
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 depends on the exact bits. (On that original slot, CPython's identity shortcut finds it again.) 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 — 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.
{"Titanium", "Levels"} is a set; {"Titanium": 245} is a dict. The colon is the whole difference."Titanium" hashes to a bucket that is already taken by an equal key, so the table just keeps the one it has.set("LEVELS") walks the string and keeps each distinct character once — six letters in, four out.discard forgives a missing item; remove does not. Pick the one that matches whether "it was not there" is a real problem for you.add, discard, clear all work. The frozen one is frozenset, and it is frozen precisely so its hash can be trusted.you type
liked = {"Titanium", "Levels", "Titanium"}
print(len(liked))
print(sorted(liked))
empty = set()
print(empty, len(empty), type(empty))
print(type({}))
liked.add("Faded")
liked.add("Levels") # already in -> nothing changes, no error
print(sorted(liked))
liked.discard("Nightcall") # missing -> silence
liked.remove("Faded") # present -> gone
print(sorted(liked), "Titanium" in liked)
print({3, 1, 2, 1})
print(set("LEVELS") == {"L", "E", "V", "S"})
try:
liked.remove("Nightcall")
except KeyError as e:
print("KeyError:", e)you see
2
['Levels', 'Titanium']
set() 0 <class 'set'>
<class 'dict'>
['Faded', 'Levels', 'Titanium']
['Levels', 'Titanium'] True
{1, 2, 3}
True
KeyError: 'Nightcall'{}is an empty dict. Typing it when you wanted a set costs you an hour:type({})isdict, and onlyset()makes an empty set.- A set has no positions.
songs[0]raisesTypeError: 'set' object is not subscriptable— there is nothing to be first. - Print a set of strings and the order is not arrival order, and it changes between runs because string hashes are salted. Wrap it in
sorted()whenever a human will read it. removeon a missing item raisesKeyError, the same error a dict raises — because underneath it is the same table.- Everything you put in must be hashable.
{[1, 2]}isTypeError: unhashable type: 'list'; freeze it withtuple(…)first.
& is what do we both like; | is everything, once; - is mine alone; ^ is what we disagree on.- is the only one that cares which side is which. mine - yours and yours - mine are different answers, and both are useful.a.union(lst), a.intersection(lst), a.difference(lst). The operators demand real sets.set(plays) is the fastest dedupe in the language, because uniqueness is not a rule anyone coded — it is how the table works.you type
mine = {"Blinding Lights", "Titanium", "Levels", "Wake Me Up"}
yours = {"Titanium", "Levels", "Sunflower", "Faded"}
print(sorted(mine | yours))
print(sorted(mine & yours))
print(sorted(mine - yours))
print(sorted(yours - mine))
print(sorted(mine ^ yours))
print({"Titanium", "Levels"} <= mine)
print(mine.isdisjoint({"Nightcall"}))
print(len(mine), len(yours), len(mine | yours))
plays = ["Levels", "Titanium", "Levels", "Faded", "Levels"]
print(len(plays), "plays ->", len(set(plays)), "distinct")
print(sorted(set(plays)))you see
['Blinding Lights', 'Faded', 'Levels', 'Sunflower', 'Titanium', 'Wake Me Up']
['Levels', 'Titanium']
['Blinding Lights', 'Wake Me Up']
['Faded', 'Sunflower']
['Blinding Lights', 'Faded', 'Sunflower', 'Wake Me Up']
True
True
4 4 6
5 plays -> 3 distinct
['Faded', 'Levels', 'Titanium']- Order matters for
-and never for&,|,^. Writing the difference backwards is the most common set bug there is. - The operators refuse anything but a set:
mine | ["Faded"]isTypeError: unsupported operand type(s) for |: 'set' and 'list'. Usemine.union([…]), or convert first. &on two lists is not an intersection at all — it is aTypeError. Convert both sides:set(a) & set(b).set(playlist)strips duplicates and throws away the order you queued them in. When the order matters, keep the list and use a set only for the asking.- These build new sets. To change one in place use
|=,&=,-=or.update— and remember every alias sees that change.
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?".
Traceback (most recent call last):
File "liked.py", line 2, in <module>
liked.add("Titanium")
^^^^^^^^^
AttributeError: 'dict' object has no attribute 'add''dict' object. Python names the type you really built — liked = {} made an empty dict, not the set you meant, and a dict files key–value pairs, so it owns no .add.set().Traceback (most recent call last):
File "walk.py", line 2, in <module>
for title, secs in lengths:
^^^^^^^^^^^
ValueError: too many values to unpack (expected 2)title, secs: the bare loop handed over one key — the string "Titanium" — and Python tried to split its eight letters across two names. Nastier: a two-letter key wouldn't crash at all; its letters would just land silently..items().Traceback (most recent call last):
File "tally.py", line 2, in <module>
plays["Levels"] = plays.get("Levels") + 1
~~~~~~~~~~~~~~~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'NoneType is the sound of a lookup that found nothing: .get with no default handed back None for the unseen key, and the crash waited until + touched it — the tildes underline the call that really misfired.NoneType in an operand error → hunt the call that returned None.# hangman_state.py -- the guess-tracking core of Ajai's Hangman, in one set and one dict
# Adapted from his 99-Projects "Hangman Game" (hangman_game.py, main.py).
# His pygame version kept guesses in a LIST; a set does the same job for free.
word = "PYTHONIC" # straight out of Ajai's own word list
lives = 6
guessed = set() # every letter tried -- each one only ever once
state = {} # letter -> "hit" or "miss", in the order they were tried
session = ["P", "A", "T", "H", "P", "O", "E", "N", "Y", "I", "C"]
for letter in session:
if letter in guessed: # one hash jump, not a scan
print(f" {letter} repeat already tried -- costs you nothing")
continue
guessed.add(letter)
if letter in word:
state[letter] = "hit"
else:
state[letter] = "miss"
misses = guessed - set(word) # every wrong letter, in one subtraction
shown = ""
for slot in word:
if slot in guessed:
shown += slot + " "
else:
shown += "_ "
print(f" {letter} {state[letter]:6} {shown} lives {lives - len(misses)}")
if len(misses) == lives:
print(" out of lives")
break
if set(word) <= guessed: # every letter of the word is in
print(" word complete")
break
print()
print("word :", word)
print("guessed :", sorted(guessed))
print("hits :", sorted(guessed & set(word)))
print("misses :", sorted(guessed - set(word)))
print("still open:", sorted(set(word) - guessed))
print("lives left:", lives - len(guessed - set(word)))
print("won :", set(word) <= guessed)
print("state :", state)
P hit P _ _ _ _ _ _ _ lives 6
A miss P _ _ _ _ _ _ _ lives 5
T hit P _ T _ _ _ _ _ lives 5
H hit P _ T H _ _ _ _ lives 5
P repeat already tried -- costs you nothing
O hit P _ T H O _ _ _ lives 5
E miss P _ T H O _ _ _ lives 4
N hit P _ T H O N _ _ lives 4
Y hit P Y T H O N _ _ lives 4
I hit P Y T H O N I _ lives 4
C hit P Y T H O N I C lives 4
word complete
word : PYTHONIC
guessed : ['A', 'C', 'E', 'H', 'I', 'N', 'O', 'P', 'T', 'Y']
hits : ['C', 'H', 'I', 'N', 'O', 'P', 'T', 'Y']
misses : ['A', 'E']
still open: []
lives left: 4
won : True
state : {'P': 'hit', 'A': 'miss', 'T': 'hit', 'H': 'hit', 'O': 'hit', 'E': 'miss', 'N': 'hit', 'Y': 'hit', 'I': 'hit', 'C': 'hit'}
guessed = [], then guessed.append(letter) — and every "have I tried this?" walks that list from the front. Swap the list for a set and two problems disappear at once: a repeat cannot be stored twice, and letter in guessed becomes one hash jump instead of a scan. That is the whole upgrade, and it is one word. Then watch the two lines that replace whole loops. His code counts a wrong guess with if letter not in word: hangman_status += 1, and decides the win with a loop that sets won = False the moment any letter is still missing. Here, guessed - set(word) is the set of wrong letters, so len() of it is the strikes; and set(word) <= guessed asks "is every letter of the word among my guesses?" in one subset test. The dict does the other half: state keeps letter → hit or miss in guess order, which is exactly what you would draw on a screen. The guesses are a fixed list rather than input() so the run is reproducible on the page — swap in input("guess: ").upper() and it is playable. Two things to try. Change word to "DEBUGGING", another of his, and watch a single G fill three slots at once. Then feed it six wrong letters and check that the run stops on the lives line instead of the win.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. →
- A list answers where is it? by looking; a dict computes where the key must live and jumps straight there.
hash()folds any frozen key down to one integer, and the low bits pick the bucket — the key becomes an address.- Collisions are routine, not failures: the newcomer probes to a nearby slot, and lookup retraces the identical route — one hash, one jump, and an
==at each door until the key answers. - The dict watches its load factor: past two-thirds full it doubles and re-hashes everything — RAM spent on emptiness so probes stay short, O(1) on average.
- A key must be immutable, so its hash can never walk away — and rip the values off a dict and what's left is a set: uniqueness and one-hop membership for free.
mine = ["Levels", "Titanium", "Faded", "Levels", "One Dance"] and yours = ["Titanium", "Closer", "Faded", "Titanium"]. Write the program twice. First the honest loop: walk mine, keep each song that is also in yours and that you have not already kept. Then the one-liner: convert both to sets and ask for the intersection with a single &. Check that the two agree. Now go after the cost, because that is the point of the whole chapter. In the loop, song in yours walks the second list from the front every time round — n songs times m songs, O(n×m). The set version hashes each song once and then answers every membership question in one jump. Finish by using the other three operators on the same data and saying out loud what each answer means: | for a merged library with no repeats, - for the songs only you play, ^ for the ones exactly one of you plays. And notice what the conversion costs you: set(mine) forgets that you played Levels three times, and forgets what you played first.show the solution
mine = ["Levels", "Titanium", "Faded", "Levels", "One Dance"]
yours = ["Titanium", "Closer", "Faded", "Titanium"]
# 1 -- THE LOOP EVERYONE WRITES FIRST: correct, but it re-scans yours every time
shared_slow = []
for song in mine:
if song in yours and song not in shared_slow:
shared_slow.append(song)
print(shared_slow)
# 2 -- THE SET VERSION: one operator, and duplicates cannot survive the trip
shared = set(mine) & set(yours)
print(sorted(shared))
print(sorted(set(mine) | set(yours)))
print(sorted(set(mine) - set(yours)))
print(sorted(set(mine) ^ set(yours)))
print(len(mine), "plays ->", len(set(mine)), "distinct")
print(sorted(shared) == sorted(shared_slow))
# ------------- what it prints -------------
['Titanium', 'Faded']
['Faded', 'Titanium']
['Closer', 'Faded', 'Levels', 'One Dance', 'Titanium']
['Levels', 'One Dance']
['Closer', 'Levels', 'One Dance']
5 plays -> 4 distinct
True"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.