◈ python mapVol 1 · Ch 07/14
Volume 1 Python, from the metal up · chapter 07

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.

iolinked · chapter 07 — the checkpoints6 steps
$ sections covered in Hash tables
01The problem: finding one song in a million
02hash(): every key becomes a number
03Buckets and collisions
04Load factor: RAM traded for speed
05Why a list can't be a key
06A set is a dict with the values ripped out

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.

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

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

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

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

SYNTAX · making a dict, reaching in by nameeight shapes — three that build a dict, five that ask it a question
name = {key: value, key: value} — the literal: braces hold the pairs, a colon glues each one name = {} — an empty dict, born ready — this is NOT an empty set name = dict(key=value, key=value) — the function: keys typed bare, so they must be plain names name[key] — DEMAND the value; a missing key raises KeyError name.get(key) — ASK for it; a missing key hands back None name.get(key, default) — ask, and name the answer you want when it is absent key in name — True or False in one jump; it tests KEYS, never values len(name) — how many pairs are filed in there
Type 1The key sits left of the colon, the value right of it. You always read by key — that direction is the whole deal.
Type 2dict(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.
Type 3song["artist"] uses the same square bracket a list does — but what goes inside is a name, not a position.
Type 4.get is the same lookup with softer manners: present → the value, absent → None, or whatever default you name.
Type 5"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'
where beginners trip
  • song["genre"] on a missing key does not hand back a blank — it raises KeyError: 'genre' and stops the program dead.
  • .get(key) with no default gives None, not 0 and not "". Then None + 1 raises TypeError a 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 is set().
  • in looks at keys only. "The Weeknd" in song is 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}.
One question, two costs: scan vs. jumpfigure
list — ask every box, one by one no no no no 200 up to n checks — O(n) the title could sit in any box dict — compute the slot, then jump "Blinding Lights" hash( ) · · 200 · 1 jump — O(1) found without ever looking
Fig 1 — Same question — how long is this song? — wildly different cost. The list interrogates box after box until it stumbles on the match. The dict runs the key through a little machine called hash() (next section), lands on a slot, and jumps. One looks; the other computes.

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

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

✗ The myth

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

✓ The reality

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

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

"One jump, always O(1)" is a labelled simplification — true enough to build your whole mental model on, and we'll refine it exactly once, here. The honest statement is that a dict lookup is O(1) on average (technically amortised). Two things hide behind that average. First, the computed slot 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. →

⚠ MOST BEGINNERS THINK…a million keys must mean a slower lookup
No scan — fine. But it still feels too good to hold: a dict carrying a million pairs must surely answer a little slower than one carrying ten — more keys, more collisions, more bookkeeping. Somewhere, the speed has to leak as the table fills.
TYPE THIS — 10 SECONDS
>>> 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 lookups
0.7066838000027929
4.1000021155923605e-06
The list walked to 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.
Wait — the list doesn't just walk slowly, it walks slowly every single time. Ask "how long is Titanium?" and it compares its way down the row; ask the exact same question a second later and it has remembered nothing — it pays the full walk again. A dict computes the address instead of finding it, so the second question is as cheap as the first. It's speed you keep, not speed you re-buy.
TWO WAYS TO ASK, TWO WAYS TO FAIL
song["genre"] insists the key exists — miss and it doesn't hand back a blank, it raises KeyError and halts the whole program. song.get("genre") asks the same question gently: present → the value, absent → None (or a default you name). Identical lookup, opposite manners the moment the key is missing.
Wait — the rule is only ever about the key. A value can be anything — a list, another dict, a whole nested playlist, even the dict itself: d = {}; d["self"] = d is perfectly legal. Only the left of the colon has to behave, because only the key is used as an address. Values are cargo; the crate never inspects what you pack inside.
A DICT HAS NO "FIRST"
A dict isn't just a faster list, and d[0] proves it. In a list that means "the first box." In a dict it means "the value filed under the key 0" — and if you never stored that key, it's a KeyError, not the first item. A dict has no positions at all, only names; there is nothing to be "first."

02hash(): every key becomes a number

Chapter 0 left you with a superpower and a catch. The superpower: RAM can leap to box 6012 without walking the street, because the address is a calculation, not a searchstart + i × 4 and you're there. The catch: that trick only works when your key already is a number. A list is happy, 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.

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

Now the leap makes sense. A dict keeps an internal array of slots — call them buckets. To store "Blinding Lights" → 200, Python computes hash("Blinding Lights"), then folds that gigantic number down onto a real slot by taking the remainder modulo the number of buckets. A brand-new dict starts with 8 buckets. A remainder modulo 8 can only ever be 07, so any 19-digit hash collapses onto a bucket that actually exists. What the bucket then stores is not the song and its length. Picture it holding two of chapter 0's 8-byte addresses — one pointing at the key object, one at the value object. (That two-pointers-in-the-bucket image is a labelled simplification. Since CPython 3.6 the bucket actually stores a small integer index into a separate dense array. 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.

SYNTAX · writing into a dictseven moves that change it — and the only one that hands your value back
d[key] = value — key absent? insert it. key present? overwrite it. no warning either way d.update(other) — merge a whole dict in; keys already there are overwritten del d[key] — a statement, not a method — nothing comes back d.pop(key) — remove it AND hand the value back; KeyError if absent d.pop(key, default) — the forgiving remove: a missing key is simply fine d.setdefault(key, default) — plant default ONLY if the key is missing; return what is there d.clear() — empty it in place; every name pointing at it now sees empty
Type 1One bracket, two jobs. library["Faded"] = 212 inserts; library["Titanium"] = 246 overwrites. Python never asks which you meant.
Type 2.update merges. New keys land at the end; a key already present keeps its position and just changes its value.
Type 3del is chapter 3's statement, the one that unbinds a name. Here it unfiles exactly one pair and returns nothing.
Type 4.pop is the only remover that hands the value back — the sole reason a name like dropped has anything in it.
Type 5.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']}
where beginners trip
  • Assignment never asks permission. A typo does not error, it creates a second entry: library["Titamium"] = 245 quietly files a song nobody will ever find.
  • del returns nothing at all, and it is a statement: gone = del d[k] is a SyntaxError. When you want the value, reach for .pop.
  • .update overwrites in silence. Merging {"Levels": 202} into a dict that already knows Levels throws the old number away without a word.
  • .setdefault returns the value, not the dict. That is why d.setdefault(k, []).append(x) works — and why d = 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.
Fold a number onto a bucket — its low 3 bits decide▸ drag me
a small int hashes to itself — so its own low bits pick the bucket 1 1 1 1 0 1 0 1 low 3 bits pick the bucket 245 & 7 = 101₂ = 5 0 1 2 3 4 5 6 7 245 hash(245) → bucket 5 245 is "Titanium" — alone in bucket 5 for now
245
Because a small int hashes to itself, the bucket is just the number's last three bits. Slide to 200 ("Blinding Lights") and it lands in bucket 0. Now slide to 8, or 16, or 248 — every one of them also lands in bucket 0, even though they're different songs. Eight buckets, 256 possible numbers: the crowding is unavoidable.
Fig 2 — Storing and looking up run the identical pipeline, so they always land in the same bucket. Only the low bits of the hash choose the slot — which is exactly why distinct keys can be forced to share one.
↻ the dict computes, it does not hunt
Almost everyone pictures a dict finding your key — a search, just mysteriously fast. It is the opposite. The dict calculates where the key must be and jumps straight there; on the happy path it never compares your key against the other keys at all. That is why a lookup in a dict of ten songs and a lookup in a dict of ten million take essentially the same time — measured, the second is about 1.01× the first. Size barely matters, because you were never scanning.
Wait — why did hash([200, 245]) throw a TypeError while the tuple hashed fine? Because a list can be changed. Picture storing something under a list key, then appending to that list: its hash would shift, the fold would point at a different bucket, and your value would be stranded in a slot nobody looks in anymore. Python refuses to open that trap. Only immutable objects — ints, strings, tuples, frozensets — are hashable and may be keys. Mutable ones (lists, dicts, sets) are barred at the door.

✗ The myth

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

✓ The reality

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

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

One — it isn't really a remainder. Saying Python does hash % 8 is a labelled simplification: 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? →

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

03Buckets and collisions

A hash function hands every key a bucket number, and lookups become a single jump instead of a search. Beautiful — until two keys are handed the same number. That's a collision, and here 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.

SYNTAX · walking a dictsix ways to loop — and what a bare for loop actually hands you
for key in d: — the bare loop yields KEYS, never values for key, value in d.items(): — both at once, unpacked from a (key, value) pair for value in d.values(): — the values alone, when the names do not matter list(d.keys()) — .keys() is a live view; list() freezes a copy of it sorted(d) — a LIST of the keys, in order — the printable-report idiom sum(d.values()) — the values are ordinary numbers; every builtin still works
Type 1for 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].
Type 2.items() yields one tuple per pass, and for k, v in … is chapter 3's unpacking doing its job again.
Type 3The order you get is the order you inserted — a real guarantee since Python 3.7, not luck. It is not sorted, and it is not bucket order.
Type 4.keys(), .values() and .items() are views, not copies. Change the dict and the view changes underneath you.
Type 5sorted(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 245
where beginners trip
  • for k, v in lengths: — forgetting .items() — raises ValueError: 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 over list(d) when you must edit.
  • A view is not a list. lengths.keys()[0] raises TypeError: 'dict_keys' object is not subscriptable — there are no positions to index.
  • Chapter 5's enumerate still works, but it numbers your walk, not the dict. A dict has no positions of its own to hand out.
How empty can a table be and still collide? Drag songs in▸ drag me
A fresh dict with 1,024 empty shelves. Drag songs in — how soon do two collide? 50% chance two songs already share a shelf BUT THE TABLE IS 96% empty only 38 of 1,024 shelves used HOW FULL THE TABLE IS HOW LIKELY THAT TWO COLLIDE 96% of shelves empty — yet already ~50% likely two songs collide. Even spread assumed. The 50/50 point sits near √shelves — ~38 songs, far below "full".
38 songs
Drag it. A nearly-empty table collides shockingly early — the 50/50 point lands near √(shelves), about 38 songs for 1,024 shelves, when the table is still 96% empty. That is exactly why Python bakes probing and resizing in from the very first key: collisions aren't the exception, they're the rule.
Fig — Pigeonhole makes collisions certain once songs outnumber shelves; probability makes them early. The tiny green bar is how full the table is; the red bar is how likely two songs already collide. They cross 50/50 at ~38 keys — while 96% of the shelves sit empty.

Python's answer is refreshingly blunt: if your home bucket is already taken, probe. Step to another slot, and keep stepping until a free one turns up, wrapping past the end back to bucket 0 if you run off the edge. The newcomer simply takes a detour. 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.

SYNTAX · the counting patternthe one dict line you will write for the rest of your life
d[k] = d.get(k, 0) + 1 — COUNT k: start from 0 if it is new, add one either way d.setdefault(k, 0) — the same idea in two lines: plant the zero… d[k] += 1 — …then it is safe to add to d.setdefault(k, []).append(x) — GROUP: one bucket list per key, filled as you go if k not in d: d[k] = 0 — the long way; correct, and two lookups where one would do
Type 1Read it right to left. d.get(k, 0) fetches the count so far — or 0 for a letter never seen — then + 1, then file it back.
Type 2That 0 is doing the real work. It answers the only awkward question: what was the count before I had ever seen this key?
Type 3setdefault takes over when the value is a container: plant [] once, then append to that same list forever after.
Type 4Words, letters, plays, votes, error codes — it is one shape. Chapter 10 hands you collections.Counter, which is this line in a box.
Type 5The counting dict comes out in first-seen order, which is often exactly what you want to print, and never what you want to rank by.
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'
where beginners trip
  • d[k] += 1 on a fresh key raises KeyError. += reads before it writes, and it is the read that fails.
  • d.get(k) without the 0 hands back None, and None + 1 is a TypeError. 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.
THE ONE IDEA TO CARRY FORWARD
A collision is not a failure — it is the expected, routine case, and open addressing turns it into a short walk. Because insertion and lookup follow the same probe route, a key that had to detour is still found in a jump or two. That is why a dict stays O(1) on average even though collisions are mathematically guaranteed.
Two songs, one home bucket — the newcomer probes onwardfigure
"Uptown Funk" · 270 s 270 & 7 = 6 "One Dance" · 174 s 174 & 7 = 6 Uptown 270 One Dance 174 0 1 2 3 4 5 6 7 both lengths land on bucket 6 — the newcomer steps to the next free slot, and lookup will retrace that exact step
Fig 03 — A collision isn't an error, it's routine. "One Dance" finds its home bucket occupied by "Uptown Funk" and probes one slot onward to 7. The table stays perfectly correct as long as everyone follows the same route in and out.

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

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

Notice the last line. Integer keys hash to themselves, so their buckets are 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.

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

✗ The myth

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

✓ The reality

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

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

The +1-until-free picture in the interactive is a labelled simplification — right in spirit, wrong in mechanics, and we'll refine it 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. →

Wait — you don't need a crowded table to get collisions — you need shockingly few keys. This is the birthday paradox in disguise: 23 people share a birthday among 365 days at even odds, and the same math says dropping just ~1,177 random keys into a table of a million empty buckets already makes it 50/50 that two collide. Collisions aren't the table filling up — they're arithmetic, and they show up almost immediately.
THE ONE COMPARISON A DICT MAKES
A dict computes the bucket — it never scans — so the single question it asks on arrival is ==: "are you actually my key, or a squatter who hashed here first?" On a healthy table that check fires once, maybe twice, and it's done. One hash, one jump, one ==. That is the entire anatomy of O(1).
Wait — what happens when a key's home is the last bucket and it's taken? The probe doesn't fall off the end — it wraps back to bucket 0, treating the row as a ring: i = (i + 1) % 8. A key born in slot 7 can end up living in slot 0. As long as insertion and lookup circle the ring the same way, the wandering key is still found in a hop or two.

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.

NOW WRITE IT YOURSELFfix the KeyError — three crashes, three of the smallest honest repairs
Three lines, three crashes, all the same error with a different name inside it. Start with 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}
THE ONE TRADE
A dict hands the CPU an address it can compute instead of a shelf it must scan. The price of that computed jump is empty space — a table kept deliberately between one-third and two-thirds vacant. Speed, paid for in RAM. Every other property in this section is a footnote to that one bargain.

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

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

✗ The myth

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

✓ The reality

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

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

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

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

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

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

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

Since 3.6 the dict is also compact: a small sparse array of slot numbers points into a dense entries array that stores key–value pairs in arrival order. That redesign cut dict memory by roughly 20–25%. 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.

NOW WRITE IT YOURSELFinvert a dict — and find out what the flip quietly eats
Start with 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? →

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

05Why a list can't be a key

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

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

So Python bans the failure mode before it can start. A dict key must be hashable: its hash must stay fixed for the object's entire life. For the built-in types that collapses to a single word — immutable (chapter 3's photographs, not its whiteboards). str, int, float, tuple, frozenset (plus bool and None) are frozen the moment they're made, so their hash can never drift. 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.

SYNTAX · what may be a keyone rule, four types that pass, three that do not — and the exact TypeError
d["Titanium"] = 245 — str: immutable, hashable, the everyday key d[200] = — int / float / bool: hashable (and 1, 1.0, True are ONE key) d[("Titanium", "Guetta")] = — tuple of hashables: a compound key, perfectly legal d[frozenset(items)] = — frozenset: the set that cannot change, so it may be a key d[["a", "b"]] = — list: TypeError: unhashable type: 'list' d[{"a": 1}] = — dict, and set too: they can change, so they are barred hash(obj) — ask the object directly: a number, or that TypeError
Type 1The rule in one word: immutable. A key's hash picks its bucket, so a key whose contents can change would walk away from its own value.
Type 2A tuple is the everyday fix. ("Titanium", "David Guetta") keys a song by title and artist — two facts, one address.
Type 3frozenset 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.
Type 4Equal keys are one key. 1, 1.0 and True are equal and hash alike, so d[1] and d[True] are the same entry.
Type 5The rule guards keys only. A value can be a list, a dict, a whole nested playlist — values are cargo, never addresses.
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'
where beginners trip
  • 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's TypeError, because hashing a tuple hashes its contents.
  • d[1] and d[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 identical TypeError.
  • The repair is one call — tuple(my_list) or frozenset(my_set). You are not converting data; you are signing a promise never to change it.
THE ONE IDEA TO CARRY FORWARD
A key's hash cannot be allowed to move, because the hash is the filing address. Anything that can change its own contents can change its own hash — and would lose track of its own data. "Hashable" is just Python's word for "safe to file by."
Who may hold a key — and who is turned awayfigure
HASHABLE — hash frozen for life UNHASHABLE — contents can change ✓ welcome as a dict key str "Blinding Lights" int 200 float 3.5 tuple ("Titanium", 245) frozenset frozenset({"Sia"}) ✗ TypeError: unhashable type list ["Levels", "Avicii"] dict {"Sia": 245} set {"Sia", "Avicii"}
Fig 1 — The whole rule on one card: frozen types (left) may be keys because their hash never moves; the three mutable containers (right) are refused on sight. Notice a value like 245 or a whole list is fine on the right-hand side of the colon — the ban is for addresses, not cargo.
keys.pypython
# a dict key must be HASHABLE — its hash fixed for the object's whole life
lengths = { ("Blinding Lights", "The Weeknd"): 200 }   # tuple key — frozen, fine
lengths[("Blinding Lights", "The Weeknd")]              # 200 — same hash, found again

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

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

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

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

✗ The myth

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

✓ The reality

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

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

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

Which makes the mutable-key bug even nastier than "always a miss." Whether the renamed key lands on an empty shelf (a clean KeyError) or happens to collide back onto its original slot 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:

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

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

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

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

06A set is a dict with the values ripped out

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

First, every element is automatically unique. Add "Titanium" to a set that already holds it, and the newcomer hashes to the exact bucket the old one occupies — 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.

SYNTAX · a set — keys with the values torn offeight shapes — and the one that quietly builds a dict instead
name = {item, item, item} — bare values, no colons: THAT is what makes it a set name = set() — the ONLY way to write an empty set — {} is a dict name = set(iterable) — anything walkable, deduplicated in one move name.add(item) — add one; adding a duplicate changes nothing at all name.discard(item) — remove if present; if it is missing, silence name.remove(item) — remove, and raise KeyError if it was not there item in name — one hash jump, O(1), however big the set gets frozenset(iterable) — the immutable twin: hashable, so it can be a key
Type 1Same braces as a dict, no colons. {"Titanium", "Levels"} is a set; {"Titanium": 245} is a dict. The colon is the whole difference.
Type 2Duplicates cannot land. The third "Titanium" hashes to a bucket that is already taken by an equal key, so the table just keeps the one it has.
Type 3set("LEVELS") walks the string and keeps each distinct character once — six letters in, four out.
Type 4discard forgives a missing item; remove does not. Pick the one that matches whether "it was not there" is a real problem for you.
Type 5A set is mutableadd, 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'
where beginners trip
  • {} is an empty dict. Typing it when you wanted a set costs you an hour: type({}) is dict, and only set() makes an empty set.
  • A set has no positions. songs[0] raises TypeError: '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.
  • remove on a missing item raises KeyError, the same error a dict raises — because underneath it is the same table.
  • Everything you put in must be hashable. {[1, 2]} is TypeError: unhashable type: 'list'; freeze it with tuple(…) first.
The one idea to carry forward
A set is a dict with the value column deleted. Everything else is a consequence: keys-only means duplicates can't coexist, and the hash table underneath means membership is a jump, not a search.
Delete the values — a set is what remainsfigure
dict · keys + values "Titanium" 245 "Levels" 203 "Faded" 212 key column value column ✂ rip out the values the keys survive untouched set · keys only "Titanium" "Levels" "Faded" "Titanium" added again — same bucket, dropped same hash table, value column gone — the keys, and only the keys, remain
Fig 6 — Delete a dict's value column and a set is what's left: the same one-jump hash table, now storing membership alone. A key that's already present hashes to a full bucket and is quietly discarded — uniqueness with no code to enforce it.
SYNTAX · set algebrafour operators, four real questions — each hands back a brand-new set
a | b — union: everything from both, each item once a & b — intersection: only what BOTH sides have a - b — difference: in a, not in b — and the order matters a ^ b — symmetric difference: in one side or the other, never both a <= b — subset test: is every item of a inside b? a.isdisjoint(b) — True when they share nothing whatsoever set(lst) — the dedupe one-liner — and it drops the order
Type 1Each operator answers a sentence. & is what do we both like; | is everything, once; - is mine alone; ^ is what we disagree on.
Type 2Every one builds a new set and leaves both originals untouched — so you can chain them without wrecking your data.
Type 3- is the only one that cares which side is which. mine - yours and yours - mine are different answers, and both are useful.
Type 4Each has a method twin that accepts any iterable: a.union(lst), a.intersection(lst), a.difference(lst). The operators demand real sets.
Type 5set(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']
where beginners trip
  • 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"] is TypeError: unsupported operand type(s) for |: 'set' and 'list'. Use mine.union([…]), or convert first.
  • & on two lists is not an intersection at all — it is a TypeError. 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.

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

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

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

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

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

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

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

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

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

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

ERROR CLINICyou will meet these — decoded
Traceback (most recent call last):
  File "liked.py", line 2, in <module>
    liked.add("Titanium")
    ^^^^^^^^^
AttributeError: 'dict' object has no attribute 'add'
The clue sits in the quotes: '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.
The rule from the warning card above, now wearing its real traceback — reach for 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)
The carets point at 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.
Section 3's tripwire warned you about exactly this line; the cure is one word — .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.
Section 1's tripwire promised this crash would move, not vanish — here is where it lands. The habit worth keeping: see NoneType in an operand error → hunt the call that returned None.
TYPE THIS · the capstone, from Ajai's 99 Projectshangman_state.py — his Hangman's guess-tracking core, rebuilt on one set and one dict
# 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'}
This is Ajai's Hangman, from his 99 Projects folder, with the graphics peeled away and the state left standing. His pygame version keeps the guesses in a listguessed = [], 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.
Two lists → one dict, in a line
dict(zip(titles, seconds)) pairs them up: keys from the first list, values from the second. The zip rules still hold — it truncates to the shorter list, and if a title repeats, the last one wins.
pair_up.pypython
titles  = ["Blinding Lights", "Titanium", "Levels"]
seconds = [200, 245, 203]

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

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

✗ The myth

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

✓ The reality

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

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

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

SAY IT BACKthe chapter in five breaths
  1. A list answers where is it? by looking; a dict computes where the key must live and jumps straight there.
  2. hash() folds any frozen key down to one integer, and the low bits pick the bucket — the key becomes an address.
  3. 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.
  4. 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.
  5. 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.
You already owned the pieces: chapter 0 gave you the address that is a calculation and the AND mask that keeps low bits; chapter 3 gave you names pointing at heap objects and the photograph-versus-whiteboard split; chapter 6 gave you the row of slots and append's occasional rebuild-the-world pause. This chapter added one move — let the key itself do the arithmetic — and the dict, the set, and all their speed fell out of it.
NOW WRITE IT YOURSELFwhat do two playlists share? — the loop version, then the one-operator version
Two friends, two play histories, both with repeats: 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
Wait — a set and a dict are the same hash table, but they keep opposite habits about order. A dict remembers the order you filled it (a guarantee since 3.7). A set does not: add "Titanium", "Blinding Lights", "Levels" and they come back Titanium, Levels, Blinding Lights — shuffled into hash order, not arrival order. Ripping out the value column threw away the bookkeeping that preserved sequence, too.
Wait — a set is mutable, yet everything inside it must be frozen. You can add and discard all day, but try {[1, 2]} and Python refuses: unhashable type: 'list'. A set files each element by its hash, so the box can change while its contents cannot. Want a collection inside a set? It needs a passport — {frozenset({1, 2})} is legal, {{1, 2}} is not.
OVERLAPPING CIRCLES, ONE KEYSTROKE EACH
Because a set is pure membership, it does the Venn-diagram algebra you drew in school — one operator per question. With mine and yours as liked-song sets: mine & yours is what you both love, mine | yours is everything deduped, mine - yours is only-mine, mine ^ yours is what neither shares, and {"Titanium", "Levels"} <= mine asks "is this a subset?" Each returns a brand-new set and leaves the originals untouched.
Trace3 step the machine — idea · code · memory move together
Dedupe for free — the duplicate that can't land
A set is a dict with the value column ripped out. Adding a key that already exists hashes to an occupied bucket, meets the equal key there, and is silently dropped — uniqueness with no code to enforce it, while membership stays a one‑hop jump. Press Next and watch the duplicate bounce off.
8 bucketskeys · no valuesadd / in · O(1)
Keys-only hash table — the value column, torn offset · 8 bucketsnew
#keyvalue · torn away
len 0
In plain words
Under the hood
NEWALU · the operation
Program · dedupe.py
variables · type
Memory · buckets + count
registers — the running state
songs · buckets keys only — value column torn off
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 07, in working code

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

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