Tarif
CV Start a project

Hash maps, from first idea to production

How hash maps and sets work, from a coat check to CPython's dict, Swiss tables and hash flooding, with demos, exercises and real production code.

Key takeaways

  • A hash map turns a key into an array index, so finding a value takes about the same time with ten entries as with ten million.
  • Collisions are guaranteed; chaining and open addressing are the two ways to handle them, and resizing keeps lookups O(1) on average.
  • Membership tests inside a loop are the classic hidden cost: x in a list is a full scan, x in a set is one jump.
  • Keys must be immutable and hash consistently with equality, and hash tables filled from untrusted input need a keyed hash such as SipHash.
  • In production the key is the design: what you put in it decides which things count as the same.

Read this first

DSABig-O

At a coat check you hand over your coat and get a ticket with a number on it. When you come back, the attendant doesn’t walk the rail asking every coat whether it’s yours. They read the number and go to that hook. It takes the same few seconds whether there are ten coats or a thousand.

A hash map is that coat check, built out of an array and a bit of arithmetic. It’s the data structure I use most in backend work, by a distance: caches, sessions, rate limiters, deduplication, joining rows in memory, counting things. Python’s objects, modules and keyword arguments are hash maps underneath. If you understand one data structure deeply, this is the one that pays back fastest.

By the end of this article you’ll be able to use dictionaries and sets fluently, explain exactly what happens inside them, solve the classic interview problems that depend on them, reason about the costs most tutorials skip (resizing, deletion, memory, attackers), and recognize where they hold real systems together. Start at Beginner if the word “hash” is new to you. If you already use dicts every day, jump to Intermediate or further with the level bar above.

Beginner: the lookup problem

Finding things is the expensive part

Most programs spend their time looking things up. Is this email already registered? What’s the price of product 4411? Which customer sent this message? The question is always the same shape: given a key, find the matching thing.

The simplest way to store things is a list. Here’s the coat check as a list of (ticket, coat) pairs:

coats = [(101, "black wool coat"), (102, "red raincoat"), (103, "denim jacket")]

def find_coat(ticket):
    for number, coat in coats:      # walk the rail, one hanger at a time
        if number == ticket:
            return coat
    return None

print(find_coat(103))
# denim jacket

That works, and with three coats it’s instant. But look at what find_coat does: it checks every pair from the start until it finds a match. With 1,000 coats, a ticket near the end takes about 1,000 checks. A ticket that isn’t there at all always takes all 1,000, because you can only say “no” after looking at everything.

Here’s the same data as a dictionary, Python’s hash map:

rail = {101: "black wool coat", 102: "red raincoat", 103: "denim jacket"}
print(rail[103])                    # go straight to hook 103
# denim jacket

rail[103] doesn’t loop. It computes where 103 lives and looks there. That’s the whole idea, and the rest of this article is about how “computes where it lives” works and what it costs.

List: check each name in turn Aisha 1 Ben 2 Chen 3 Dana 4 Eli 5 Farah 5 checks Hash map: compute the slot, go there Eli hash() slot 4 · · · · Eli ·
A list answers 'where is Eli?' by checking names in order. A hash map turns 'Eli' into a slot number and reads that one slot.

Dictionaries: keys and values

A dictionary stores entries. Each entry is a key (what you look up by) and a value (what you get back). Keys are unique: storing a second value under the same key replaces the first.

stock = {"apple": 12, "banana": 0}

stock["cherry"] = 40            # insert a new key
stock["apple"] = 11             # update an existing key
print(stock["apple"])           # read
print("banana" in stock)        # is the key there?
print(stock.get("durian"))      # missing key: None instead of an error
print(stock.get("durian", 0))   # ...or a default you choose
del stock["banana"]             # delete
print(len(stock), stock)

for fruit, count in stock.items():
    print(fruit, count)
# 11
# True
# None
# 0
# 2 {'apple': 11, 'cherry': 40}
# apple 11
# cherry 40

Five operations matter, and all of them are fast no matter how big the dictionary gets:

Operation Python What it does
Insert or update d[key] = value Store a value under a key
Read d[key] or d.get(key) Get the value ([] raises KeyError if missing, .get returns a default)
Membership key in d Is the key there?
Delete del d[key] Remove the entry
Size len(d) How many entries

Two things trip people up early. First, in checks keys, not values: "apple" in stock is fast, while 11 in stock.values() walks every value one by one. Second, d[key] on a missing key raises KeyError. Use .get when “missing” is a normal case rather than a bug.

Counting with a dictionary

The single most common thing I do with a dictionary is count. Here’s a word count, by hand and then with the standard library:

text = "the cat sat on the mat the end"

counts = {}
for word in text.split():
    counts[word] = counts.get(word, 0) + 1
print(counts)

from collections import Counter
print(Counter(text.split()).most_common(2))
# {'the': 3, 'cat': 1, 'sat': 1, 'on': 1, 'mat': 1, 'end': 1}
# [('the', 3), ('cat', 1)]

counts.get(word, 0) + 1 reads “the count so far, or zero if this is the first time, plus one”. Counter is a dictionary subclass that does the same thing and adds helpers like most_common. Counting votes, errors per endpoint, messages per customer, letters in a word: it’s all this loop.

Sets: a dictionary with only keys

Sometimes there’s no value to store. You only want to know whether you’ve seen something. That’s a set: a collection of unique keys with the same fast membership test.

seen = {"ali", "ben"}
seen.add("chen")
seen.add("ali")                  # already there: nothing changes
print(len(seen), "chen" in seen)

a = {"python", "sql", "aws"}
b = {"python", "go"}
print(a & b)                     # in both
print(a | b == {"python", "sql", "aws", "go"})   # in either
print(a - b == {"sql", "aws"})   # in a but not b

emails = ["x@a.com", "y@b.com", "x@a.com", "z@c.com", "y@b.com"]
print(list(dict.fromkeys(emails)))   # dedupe, keep first-seen order
# 3 True
# {'python'}
# True
# True
# ['x@a.com', 'y@b.com', 'z@c.com']

A set has no order you can rely on, which is why I compared sets with == above instead of printing them. When you want to remove duplicates and keep the original order, dict.fromkeys(items) does it, because dictionaries remember insertion order (more on why in Advanced).

The bug hiding in in

Here’s the mistake I see most in code reviews. It reads perfectly well, passes every test with small data, and falls over in production:

import time

staff_ids = list(range(0, 200_000, 2))      # 100,000 even numbers
senders = list(range(0, 20_000))            # 20,000 incoming senders

start = time.perf_counter()
internal = [s for s in senders if s in staff_ids]          # list: a hidden loop
list_seconds = time.perf_counter() - start

staff_set = set(staff_ids)                                  # built once
start = time.perf_counter()
internal_fast = [s for s in senders if s in staff_set]     # set: one jump each
set_seconds = time.perf_counter() - start

print(internal == internal_fast, len(internal))
print(f"list: {list_seconds:.2f} s   set: {set_seconds * 1000:.2f} ms")
# True 10000
# list: 5.97 s   set: 0.82 ms

Same answer, about seven thousand times faster, on my laptop. s in staff_ids looks like one step, but on a list it’s a loop over up to 100,000 items, and it runs 20,000 times. That’s up to two billion comparisons. On a set, each in is one hash and one or two slot checks.

Race the two yourself. Both machines hold the same 16 numbers:

Try it

Find one number: list vs set

Both hold the same 16 user IDs. The set stores them in 32 slots at hash(id) % 32, and for small integers Python's hash(n) is just n. Pick a number and race them.

a_listcomparisons 0

0482
1117
2905
333
4760
5251
6648
719
8374
9830
10566
1192
12713
13205
14941
15158

Walks left to right, asking each element "are you x?"

a_setslot checks 0

0·
133
2482
3·
4·
5·
6·
7·
8648
9905
10713
11·
12·
13205
14941
15·
16·
17·
18·
1919
20·
21117
22374
23566
24760
25·
26·
27251
2892
29·
30830
31158

Computes the slot where x would live and looks only there.

Try "last" and "missing". The list does its worst work exactly when the answer is no, and inside a loop that's the common case.

The cost of the list version is senders × staff, an area. The cost of the set version is senders + staff, a line: one pass to build the set, one pass to check. Areas grow much faster than lines:

Try it

Put the lookup inside a loop

Both collections have the same number of items. Drag to see when the difference stops being academic.

list: comparisons100,000,000

set: operations20,000

set is faster by×1,000

list1 s
set1 ms

Bars use a log scale: each step right is ten times longer. Rough CPython costs on a laptop: about 10 ns per list comparison, about 50 ns per set insert or lookup, with most items not found (the worst case for the list). At 10 items they tie, which is why the slow version passes every test.

The rule to keep: when you see in inside a loop, look at what’s on the right of it. A list there means a second, hidden loop.

When a list is still the right choice

Hash maps aren’t free, and sometimes a list is better:

  • The collection is tiny and the check isn’t in a hot loop. status in ("pending", "sent", "failed") scans three items. That costs nothing.
  • You only check once. Building a set walks the whole list anyway, so for a single in, the list is just as fast.
  • You need order, duplicates or positions. Sets have none of those, and dictionaries only remember insertion order.
  • The items can’t be hashed, like lists or dictionaries. More on that in Intermediate.

Exercise · BeginnerThe first repeated email

You’re importing a sign-up list and want to stop at the first email that appears twice. Write first_repeat(items) that returns the first item seen a second time, or None. It should take one pass over the list.

Show solution

Keep a set of everything seen so far. Each item is either already in the set (that’s your answer) or gets added.

def first_repeat(items):
    seen = set()
    for item in items:
        if item in seen:
            return item
        seen.add(item)
    return None

print(first_repeat(["x@a.com", "y@b.com", "z@c.com", "y@b.com", "x@a.com"]))
print(first_repeat([1, 2, 3]))
# y@b.com
# None

One pass and one set lookup per item: the time grows in a straight line with the list. The version with a nested loop (compare each item with every earlier one) grows with the square.

Exercise · BeginnerSame letters?

Two words are anagrams if they use exactly the same letters the same number of times (“listen” and “silent”). Write is_anagram(a, b) using a dictionary or Counter.

Show solution

Count the letters of each word and compare the counts.

from collections import Counter

def is_anagram(a, b):
    return Counter(a) == Counter(b)

print(is_anagram("listen", "silent"), is_anagram("rat", "car"))
# True False

Sorting both words and comparing also works, but sorting costs more than counting as words get long, and counting generalizes: the same idea groups anagrams in the Intermediate exercises.

Intermediate: how a hash map works inside

You’ve seen what a dictionary does. Now for how: how does rail[103] know where to look?

Step 1: turn the key into a number

A hash map is built on an ordinary array, and arrays are indexed by position: slot 0, slot 1, slot 2. So the first job is to turn any key, a string, a number, a tuple, into a number. That’s the hash function. A good one has three properties:

  1. Deterministic. The same key always gives the same number, at least for the life of the program. Otherwise you’d store something and never find it again.
  2. Fast. It runs on every single lookup.
  3. Spreads keys out. Similar keys (“cat”, “act”, “cab”) should get very different numbers, so they don’t all crowd into the same place.

Here’s a real, simple hash function. It’s the recipe Java uses for strings: start at 0, and for each character multiply by 31 and add the character’s code.

def toy_hash(key: str) -> int:
    h = 0
    for ch in key:
        h = (h * 31 + ord(ch)) % 2**32   # the same recipe as Java's String.hashCode
    return h

for word in ["cat", "dog", "act", "Aa", "BB"]:
    h = toy_hash(word)
    print(f"{word:>4}  hash={h:>6}  slot={h % 8}")
#  cat  hash= 98262  slot=6
#  dog  hash= 99644  slot=4
#  act  hash= 96402  slot=2
#   Aa  hash=  2112  slot=0
#   BB  hash=  2112  slot=0

“cat” and “act” have the same letters but different hashes, because multiplying by 31 at each step makes position matter. Keep an eye on “Aa” and “BB”: different strings, identical hash. That matters a lot in the Expert section.

Step 2: squeeze the number into a slot

The hash can be any size, and the array has only a few slots, so the second step squeezes the hash into range with the remainder operator: slot = hash % capacity. With 8 slots, 98262 % 8 = 6, so “cat” lives in slot 6.

"cat" h = h·31 + char 98,262 a big number 98,262 % 8 = 6 squeezed into 0…7, the number of slots 0 · 1 · 2 · 3 · 4 · 5 · 6 cat 7 ·
The whole trick in two steps. Every lookup for 'cat' repeats exactly this arithmetic and lands on slot 6 again.

Storing is: hash the key, find the slot, put the entry there. Looking up is: hash the key, find the slot, check what’s there. Neither step depends on how many other keys exist. That’s where the speed comes from.

Collisions are guaranteed

There are far more possible keys than slots, so two keys will sooner or later land in the same slot. That’s a collision, and it isn’t rare. It’s the birthday problem: with 23 people in a room, there’s a better than even chance two share a birthday. With 8 slots and only 4 keys, the chance that at least two collide is 1 − (8 × 7 × 6 × 5) / 8⁴ = 1 − 1,680 / 4,096, about 59%.

So every hash map needs a plan for collisions. There are two families.

Separate chaining. Each slot holds a small list (a “chain”) of every entry that landed there. Insert appends to the chain; lookup walks the chain comparing keys. Java’s HashMap and Go’s maps work this way.

Open addressing. There are no lists: the array itself holds the entries. If the slot is taken, try another slot by a fixed rule and keep going until you find the key or an empty slot. The simplest rule is linear probing: try the next slot, then the next. Python’s dict and set use open addressing, with a cleverer rule we’ll see in Advanced.

Separate chaining each slot holds a small list 0 1 2 3 4 5 6 7 dog owl elk cat Open addressing, linear probing one flat array; a taken slot means try the next 0 · 1 · 2 · 3 · 4 dog 5 owl 6 cat 7 elk elk: 4, 5, 6 taken → 7
dog, owl and elk all hash to slot 4 in an 8-slot table. Chaining keeps them in one list. Linear probing pushes them into the following slots, and elk has to walk past cat, which belongs to slot 6, before it finds room.

Notice what probing did to elk: it got pushed into slot 7, and it had to step past cat, which had nothing to do with slot 4. Keys that collide form a cluster, and clusters attract more collisions. That’s linear probing’s weakness. Its strength is that one flat array is very friendly to the CPU cache: the next slot is usually already in memory.

Build one yourself

The best way to own this is to write it. Here’s a complete hash map with separate chaining, in about forty lines. It uses Python’s built-in hash() for step 1 and grows when it gets crowded (the _resize part is explained in Advanced).

class ChainedHashMap:
    """A hash map that keeps colliding keys in a list per bucket (separate chaining)."""

    def __init__(self, capacity=8, max_load=0.75):
        self.buckets = [[] for _ in range(capacity)]
        self.size = 0
        self.max_load = max_load

    def _bucket(self, key):
        return self.buckets[hash(key) % len(self.buckets)]

    def put(self, key, value):
        bucket = self._bucket(key)
        for i, (k, _) in enumerate(bucket):
            if k == key:                  # key already here: overwrite
                bucket[i] = (key, value)
                return
        bucket.append((key, value))
        self.size += 1
        if self.size / len(self.buckets) > self.max_load:
            self._resize(len(self.buckets) * 2)

    def get(self, key, default=None):
        for k, v in self._bucket(key):
            if k == key:
                return v
        return default

    def remove(self, key):
        bucket = self._bucket(key)
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket.pop(i)
                self.size -= 1
                return True
        return False

    def _resize(self, new_capacity):
        old = self.buckets
        self.buckets = [[] for _ in range(new_capacity)]
        for bucket in old:
            for k, v in bucket:
                self.buckets[hash(k) % new_capacity].append((k, v))

m = ChainedHashMap()
for i, word in enumerate(["cat", "dog", "owl", "fox", "elk", "bee", "ant"]):
    m.put(word, i)
print(m.get("fox"), m.get("yak"), m.size, len(m.buckets))
m.put("fox", 99)
m.remove("cat")
print(m.get("fox"), m.get("cat"), m.size)
# 3 None 7 16
# 99 None 6

Notice that get compares keys with == after finding the bucket. The hash only narrows the search to one bucket. Two different keys can share a hash, so the final word always belongs to equality.

Now play with one. The demo below uses the toy_hash recipe from above, so you can check its arithmetic by hand:

Try it

Build a hash table

Type a short key and insert it. Watch where it lands, what a collision does, and when the table grows. Then switch to linear probing and try deleting.

hash("cat") = 98,262 → 98,262 % 8 = slot 6

0empty
1empty
2empty
3empty
4dogowl
5empty
6cat
7empty

3 keys8 slotsload factor 0.38

Three keys are already in. Insert "fox" to start.

Things to try: press “Add a colliding key” a few times in chaining mode and watch one chain grow. Insert until the load factor passes 0.75 and watch every key move. Switch to linear probing, delete a key from the middle of a cluster, and then look up a key that sat after it.

Why it’s O(1) on average, and when it isn’t

“O(1)” means the cost doesn’t grow with the number of items. Here’s the reasoning for a hash map, without hand-waving.

Call the number of keys n and the number of slots m. The ratio α = n / m is the load factor: how full the table is on average. If the hash function spreads keys evenly, each chain has about α entries. A lookup does one hash, jumps to a slot, and walks one chain. A successful lookup walks about half a chain plus one; an unsuccessful lookup walks the whole chain. So the cost is about 1 + α.

The trick is that hash maps never let α get big. When α passes a threshold (0.75 in Java, about two-thirds in CPython), they double the array, which halves α. Since α stays below a fixed number, 1 + α stays below a fixed number, no matter whether you have a hundred keys or a hundred million. That’s O(1).

Now the worst case. If every key lands in the same slot, one chain holds all n keys and every lookup walks it: O(n), no better than a list. That happens with a broken hash function, or with an attacker choosing keys on purpose. Remember “Aa” and “BB”.

Operation Average Worst case
Insert O(1) (amortized, see Advanced) O(n)
Lookup / in O(1) O(n)
Delete O(1) O(n)
Iterate all entries O(n) O(n)
Memory O(n) O(n)

What can be a key

Not everything can be a key. A key’s hash must not change while it’s stored, because the table filed it under that hash. If it changed, the entry would sit in a slot nobody looks in.

That’s why Python refuses mutable built-ins as keys:

location = {(3, 4): "warehouse", (0, 0): "office"}   # tuples are fine as keys
print(location[(3, 4)])

try:
    {[3, 4]: "warehouse"}
except TypeError as e:
    print("TypeError:", e)

tags = frozenset({"vip", "late"})
print({tags: "follow up"}[frozenset({"late", "vip"})])
# warehouse
# TypeError: unhashable type: 'list'
# follow up

A list can be changed after you store it, so it has no hash. A tuple can’t be changed, so it can be a key. Same for frozenset versus set. The rule of thumb: use immutable things as keys: strings, numbers, tuples of those, frozen dataclasses. When you need a composite key, like “this user on this date”, use a tuple: visits[(user_id, day)].

Patterns that come up again and again

Nearly every hash map problem is one of a handful of moves: remember what you’ve seen, count, group by a computed key, or look up a complement. These five exercises cover them. Try each before opening the solution.

Exercise · IntermediateTwo Sum

Given a list of numbers and a target, return the positions of the two numbers that add up to the target. Assume exactly one answer exists if any does. two_sum([2, 7, 11, 15], 9) returns [0, 1].

The obvious solution checks every pair:

def two_sum_slow(nums, target):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] + nums[j] == target:
                return [i, j]
    return None

print(two_sum_slow([2, 7, 11, 15], 9))
# [0, 1]

That’s O(n²). Do it in one pass.

Show solution

For each number, the partner you need is target - n. Instead of searching for it, remember every number you’ve passed in a dictionary from value to position. Then “have I seen my partner?” is one lookup.

def two_sum(nums, target):
    index_of = {}                       # value -> index where we saw it
    for i, n in enumerate(nums):
        need = target - n
        if need in index_of:            # have we already seen the partner?
            return [index_of[need], i]
        index_of[n] = i
    return None

print(two_sum([2, 7, 11, 15], 9))
print(two_sum([3, 2, 4], 6))
print(two_sum([3, 3], 6))
print(two_sum([1, 2], 7))
# [0, 1]
# [1, 2]
# [0, 1]
# None

Two details matter. We check for the partner before storing the current number, so [3, 2, 4] with target 6 doesn’t pair the 3 with itself. And [3, 3] still works, because the first 3 is stored by the time we reach the second. Time O(n), memory O(n).

Exercise · IntermediateGroup anagrams

Group words that are anagrams of each other. ["eat", "tea", "tan", "ate", "nat", "bat"] becomes [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]. Lowercase letters only.

Show solution

Anagrams share a signature, something that’s equal for all of them and different for everything else. The letter counts are one. Use the signature as the key and collect words under it. A list of counts can’t be a key, so convert it to a tuple.

from collections import defaultdict

def group_anagrams(words):
    groups = defaultdict(list)
    for w in words:
        key = [0] * 26                  # letter counts: same for every anagram
        for ch in w:
            key[ord(ch) - ord("a")] += 1
        groups[tuple(key)].append(w)    # lists can't be keys, tuples can
    return list(groups.values())

print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
# [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]

"".join(sorted(w)) is another valid signature, at O(k log k) per word instead of O(k). The general move, “compute a canonical key, then group by it”, is everywhere: grouping log lines by error type, orders by customer, files by content hash.

Exercise · IntermediateSubarray sum equals K

Count the contiguous runs in a list whose sum is exactly k. subarray_sum([1, 1, 1], 2) is 2. Numbers can be negative, which rules out a sliding window.

Show solution

Keep a running total (a prefix sum). The sum of the run from position i + 1 to j is prefix[j] - prefix[i]. We want that to equal k, so at each position we ask: how many earlier prefix sums equal running - k? A dictionary counting prefix sums answers that in one lookup.

def subarray_sum(nums, k):
    count = 0
    running = 0
    seen = {0: 1}                       # prefix sum -> how many times we've had it
    for n in nums:
        running += n
        count += seen.get(running - k, 0)
        seen[running] = seen.get(running, 0) + 1
    return count

print(subarray_sum([1, 1, 1], 2))
print(subarray_sum([1, 2, 3], 3))
print(subarray_sum([3, 4, 7, 2, -3, 1, 4, 2], 7))
# 2
# 2
# 4

The {0: 1} seed handles runs that start at the very beginning. In the last example the four runs are [3, 4], [7], [7, 2, -3, 1] and [1, 4, 2]. This “prefix sum plus hash map” pairing is one of the most useful tricks in the toolbox: it turns “count ranges with property X” from O(n²) into O(n).

Exercise · IntermediateLongest consecutive sequence

Given unsorted integers, return the length of the longest run of consecutive values. [100, 4, 200, 1, 3, 2] returns 4 (for 1, 2, 3, 4). Do it in O(n), so no sorting.

Show solution

Put everything in a set. A number starts a run only if n - 1 isn’t present. From each start, count upward with set lookups.

def longest_consecutive(nums):
    present = set(nums)
    best = 0
    for n in present:
        if n - 1 in present:            # not the start of a run: skip it
            continue
        length = 1
        while n + length in present:
            length += 1
        best = max(best, length)
    return best

print(longest_consecutive([100, 4, 200, 1, 3, 2]))
print(longest_consecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1]))
print(longest_consecutive([]))
# 4
# 9
# 0

It looks like a loop inside a loop, but each number is visited by the inner while at most once overall: only run starts begin counting, and runs don’t overlap. So the total work is O(n). Learning to count work like that, across the whole run instead of per loop, is the key to the Advanced section.

Exercise · IntermediateFirst unique character

Return the position of the first character that appears exactly once in a string, or -1. "leetcode" gives 0, "loveleetcode" gives 2.

Show solution

Two passes: count everything, then walk the string again and return the first character with count 1.

from collections import Counter

def first_unique(s):
    counts = Counter(s)                 # pass 1: count every character
    for i, ch in enumerate(s):          # pass 2: first one seen exactly once
        if counts[ch] == 1:
            return i
    return -1

print(first_unique("leetcode"), first_unique("loveleetcode"), first_unique("aabb"))
# 0 2 -1

O(n) time. The memory is bounded by the alphabet size, so for a fixed alphabet it’s O(1), which is a good detail to mention in an interview.

Advanced: the costs tutorials skip

Average O(1) is true, but it’s hiding three costs: growing the table, deleting from it, and the memory it eats. And every language makes different choices about them.

Load factor and resizing

As keys arrive, the load factor climbs and chains (or probe sequences) get longer. At a threshold the table resizes: it allocates an array twice as big and re-inserts every key. Every key has to be re-placed, because its slot was hash % 8 and is now hash % 16, a different number for about half the keys.

Before: 8 slots, 6 keys, load 0.75 0 cod 1 · 2 bee 3 yak 4 dog 5 · 6 cat 7 fox grow to 16 slots After: 16 slots, load 0.375 0 · 1 · 2 bee 3 yak 4 · 5 · 6 cat 7 · 8 cod 9 · 10 · 11 · 12 dog 13 · 14 · 15 fox
Six keys at load 0.75, then the same keys in 16 slots. With power-of-two sizes each key either stays put or moves exactly 8 slots up, depending on one more bit of its hash.

Most implementations keep the capacity a power of two. Then hash % capacity is the same as hash & (capacity - 1), a single bitwise AND instead of a division, and after doubling, each key either stays in its slot or moves up by exactly the old capacity. You can see that in the diagram: cod moves from 0 to 8, dog from 4 to 12, fox from 7 to 15, and the rest stay.

Why resizing is still O(1): amortized cost

Resizing a table of a million keys copies a million keys. How can insert be O(1) if some inserts cost a million steps?

Because those expensive inserts are rare, and they get rarer as the table grows. Doubling at sizes 8, 16, 32, … means the copies cost 6 + 12 + 24 + … and every term is half the next one. A sum like that is always less than twice its last term, and the last term is less than n. So n inserts cost fewer than n + 2n steps in total: a constant number of steps per insert, on average over the whole sequence. That’s what amortized O(1) means.

You don’t have to take the algebra on trust. Count it:

def count_work(n, grow=2, start=8, max_load=0.75):
    capacity, size, copies = start, 0, 0
    for _ in range(n):
        if (size + 1) / capacity > max_load:
            copies += size          # re-insert every existing key
            capacity *= grow
        size += 1
    return copies, capacity

for n in (1_000, 1_000_000):
    copies, cap = count_work(n)
    print(f"{n:>9,} inserts: {copies:>9,} extra copies, {(n + copies) / n:.2f} steps per insert, final capacity {cap:,}")
#     1,000 inserts:     1,530 extra copies, 2.53 steps per insert, final capacity 2,048
# 1,000,000 inserts: 1,572,858 extra copies, 2.57 steps per insert, final capacity 2,097,152

A thousand times more inserts, and the work per insert barely moves. Growing by a fixed amount instead of a fixed factor (say, 1,000 slots at a time) breaks this: the copies become 1,000 + 2,000 + 3,000 + …, which grows with n², and insert stops being O(1).

Amortized isn’t the same as “every insert is fast”, though. The insert that triggers the resize really does take a million steps, all at once. For most code that’s invisible. For a latency-sensitive server holding a huge table, it’s a pause. Redis avoids it with incremental rehashing: during a resize it keeps both the old and new tables and moves a few buckets on every operation, so no single request pays for the whole move. Go’s maps grew incrementally in the same spirit.

Deleting under open addressing: tombstones

With chaining, delete is easy: remove the entry from its chain. With open addressing it’s a trap. A lookup stops at the first empty slot, because “empty” means “the key would have been here”. If you delete by emptying a slot in the middle of a probe sequence, you cut the sequence, and keys after the hole become invisible.

EMPTY, TOMBSTONE = object(), object()

class ProbingSet:
    """Open addressing with linear probing: one array, no lists."""

    def __init__(self, capacity=8):
        self.slots = [EMPTY] * capacity

    def _probe(self, key):
        i = hash(key) % len(self.slots)
        while True:                     # the table is never full in this demo
            yield i
            i = (i + 1) % len(self.slots)

    def add(self, key):
        reuse = None
        for i in self._probe(key):
            slot = self.slots[i]
            if slot is EMPTY:
                self.slots[reuse if reuse is not None else i] = key
                return
            if slot is TOMBSTONE:
                reuse = i if reuse is None else reuse   # remember the first grave
            elif slot == key:
                return                                    # already present

    def __contains__(self, key):
        for i in self._probe(key):
            slot = self.slots[i]
            if slot is EMPTY:
                return False            # a true gap ends the search
            if slot is not TOMBSTONE and slot == key:
                return True

    def remove_wrong(self, key):
        for i in self._probe(key):
            if self.slots[i] == key:
                self.slots[i] = EMPTY   # punches a hole in the probe chain
                return

    def remove(self, key):
        for i in self._probe(key):
            if self.slots[i] is EMPTY:
                return
            if self.slots[i] == key:
                self.slots[i] = TOMBSTONE   # "something was here, keep looking"
                return

# 1, 9 and 17 all land on slot 1 in an 8-slot table (hash(n) == n for small ints).
s = ProbingSet()
for n in (1, 9, 17):
    s.add(n)
s.remove_wrong(9)
print("wrong delete, is 17 still there?", 17 in s)

s = ProbingSet()
for n in (1, 9, 17):
    s.add(n)
s.remove(9)
print("tombstone delete, is 17 still there?", 17 in s)
# wrong delete, is 17 still there? False
# tombstone delete, is 17 still there? True

The fix is a tombstone: a marker that says “something used to be here, keep searching”. Lookups walk past tombstones; inserts may reuse them. The catch is that tombstones still make searches longer, so they count toward the load factor, and a table with lots of deletes slowly fills with graves until a resize clears them. CPython’s dict does exactly this; its tombstones are called “dummy” entries. You can watch it in the demo above: switch to linear probing, delete a key, and the † stays until the next resize.

Inside CPython’s dict

Python’s dict is one of the most heavily tuned hash tables in common use, and knowing its layout explains several behaviors that otherwise look like magic.

Since Python 3.6 the dict is compact: instead of one big array of (hash, key, value) slots, it keeps two arrays. A small, sparse indices array holds positions, and a dense entries array holds the actual (hash, key, value) triples, appended in insertion order.

indices: 8 slots, 1 byte each here 0 – 1 – 2 2 3 – 4 1 5 – 6 0 7 – each number is a row in entries below entries: dense, in insertion order rowhashkeyvalue 0 98262 'cat' 1 1 99644 'dog' 2 2 97410 'bee' 3 lookup: hash → slot → row → compare key
A lookup hashes the key, finds its slot in the small indices array, follows the number to a row in the entries array, then compares the key. Slot positions here use the article's toy hash; CPython's real string hash is randomized per process.

Three consequences fall out of that layout:

  1. Insertion order is free. Iterating a dict walks the dense entries array, which is in insertion order. It started as an implementation detail in 3.6 and became a language guarantee in 3.7.
  2. Less memory. Empty slots cost only one small index (1 byte each while the table has fewer than 256 slots, growing to 2, 4 or 8 bytes for larger tables) instead of a whole 24-byte triple.
  3. Faster iteration. Walking a dense array touches no empty slots.

CPython keeps the table at most about two-thirds full. You can watch it grow with sys.getsizeof (Python 3.13 on my machine):

import sys
d = {}; last = sys.getsizeof(d)
for i in range(1, 100):
    d[i] = i
    if sys.getsizeof(d) != last:
        last = sys.getsizeof(d)
        print(f"after {i} keys -> {last} bytes")
# after 1 keys -> 224 bytes
# after 6 keys -> 352 bytes
# after 11 keys -> 632 bytes
# after 22 keys -> 1168 bytes
# after 43 keys -> 2264 bytes
# after 86 keys -> 4688 bytes

An 8-slot table holds 5 keys, so the 6th triggers a resize; 16 slots hold 10, so the 11th does; then 21, 42, 85. Two-thirds every time.

For collisions, CPython doesn’t use plain linear probing. Small integers hash to themselves, so keys like 1, 2, 3, … would sit in neighboring slots, and linear probing would turn them into one long cluster. Instead it mixes in the higher bits of the hash as it probes:

perturb >>= PERTURB_SHIFT;          /* PERTURB_SHIFT is 5 */
i = (i*5 + perturb + 1) & mask;

Early probes are steered by bits of the hash that & mask threw away, so keys that collided on the low bits quickly diverge. Once perturb reaches zero, i*5 + 1 alone still visits every slot of a power-of-two table, so the search always terminates.

A few behaviors that follow from how Python hashes, all verified on 3.13:

print(hash(1), hash(1.0), hash(True))        # equal values must hash equally
d = {1: "int", True: "bool", 1.0: "float"}
print(d)                                      # one key: they're all "equal"

print(hash(-1), hash(-2))                     # -1 is reserved as an error code in C

d = {}
d["b"] = 1; d["a"] = 2; d["c"] = 3
print(list(d))                                # insertion order, guaranteed since 3.7
# 1 1 1
# {1: 'float'}
# -2 -2
# ['b', 'a', 'c']

1, 1.0 and True compare equal, so they must hash equal, so they’re the same key: the dict keeps the first key object and the last value. And hash(-1) is -2 because CPython’s C functions return -1 to signal an error, so no real hash is allowed to be -1.

Sets aren’t dicts

Python’s set has its own implementation. It has no values and doesn’t remember order, and it’s tuned differently: it probes a run of neighboring slots linearly before jumping (cheap, because neighbors share a cache line), and it resizes at about 60% full, growing four times larger while small. That’s why a set of a million integers is a little smaller than a dict of the same keys, and why you should never rely on a set’s iteration order: it can change between runs and versions.

Java and Go make different choices

Java’s HashMap uses separate chaining over a power-of-two array (default capacity 16, load factor 0.75). Because hash & (capacity - 1) only looks at the low bits, it first mixes the high bits down with h ^ (h >>> 16). Its most interesting feature is a defense: since Java 8, when one bucket’s chain reaches 8 entries (and the table has at least 64 slots), the chain is converted into a balanced red-black tree. The worst case for that bucket drops from O(n) to O(log n). It shrinks back to a list at 6, so it doesn’t flip back and forth at the boundary.

Go’s maps were, until Go 1.24, arrays of buckets that each held 8 entries plus 8 one-byte “tophash” values (the top bits of each key’s hash), with overflow buckets chained on when full, and growth spread across later operations. Go also randomizes iteration order on purpose, so nobody can accidentally depend on it. Go 1.24 replaced the implementation with Swiss tables, which are in the Expert section.

The hash and equality contract

When you put your own objects in a dict or set, you’re promising two things:

  1. If a == b, then hash(a) == hash(b). Equal keys must land in the same slot, or the table looks in the wrong place.
  2. The hash doesn’t change while the object is a key.

Python enforces part of this for you: define __eq__ without __hash__ and your class becomes unhashable. Break rule 2 and you get the nastiest kind of bug, an entry that’s still in the dict but can’t be found by any key:

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)

try:
    {Point(1, 2): "home"}
except TypeError as e:
    print("TypeError:", e)       # defining __eq__ removes the default __hash__

class MutablePoint(Point):
    def __hash__(self):
        return hash((self.x, self.y))

p = MutablePoint(1, 2)
places = {p: "home"}
p.x = 5                          # mutate the key after storing it
print(p in places)               # looks in the bucket for (5, 2): not there
print(MutablePoint(1, 2) in places)   # right bucket, but the stored key now equals (5, 2)
print(len(places), "entry still in the dict, unreachable by any key")
# TypeError: unhashable type: 'Point'
# False
# False
# 1 entry still in the dict, unreachable by any key

The stored entry was filed under the hash of (1, 2). After the mutation, looking up p hashes (5, 2) and searches the wrong slot. Looking up a fresh (1, 2) searches the right slot but finds a key that now equals (5, 2). The fix is to make keys immutable. A frozen dataclass gets a correct __eq__ and __hash__ and refuses mutation:

from dataclasses import dataclass

@dataclass(frozen=True)          # frozen: fields can't change, so the hash can't drift
class Point:
    x: int
    y: int

places = {Point(1, 2): "home"}
print(places[Point(1, 2)])
try:
    Point(1, 2).x = 5
except Exception as e:
    print(type(e).__name__)
# home
# FrozenInstanceError

Exercise · AdvancedWhy does my cache never hit?

A teammate memoizes an expensive report like this, and the cache never returns anything. Explain why, and fix it.

cache = {}
def report(filters):            # filters is a dict like {"country": "MY", "year": 2026}
    key = str(filters)
    if key not in cache:
        cache[key] = build_report(filters)
    return cache[key]
Show solution

str(filters) depends on the order the caller built the dict in: {"country": "MY", "year": 2026} and {"year": 2026, "country": "MY"} are equal dicts but produce different strings, so logically identical requests get different keys. (Using the dict itself as the key would fail outright: dicts are unhashable.)

Build a canonical, immutable key: the same request must always produce the same key.

key = tuple(sorted(filters.items()))   # order-independent and hashable

If values can themselves be lists or dicts, convert them recursively, or serialize with json.dumps(filters, sort_keys=True). “Two equal things must produce one key” is the hash contract applied to cache design.

Exercise · AdvancedWhat does a million integers cost?

Estimate the memory for one million integers stored as a list, a set, and a dict (keys only, value None), counting only the container, not the integer objects. Then explain the difference.

Show solution

Measured with sys.getsizeof on Python 3.13:

import sys
n = 1_000_000
print('list', sys.getsizeof(list(range(n))), 'set', sys.getsizeof(set(range(n))), 'dict', sys.getsizeof(dict.fromkeys(range(n))))
# list 8000056 set 33554648 dict 41943128

About 8 MB, 34 MB and 42 MB. The list stores one 8-byte pointer per item. The set stores a hash and a pointer per slot (16 bytes) and keeps plenty of slots empty. The dict stores hash, key and value per entry (24 bytes) plus the index array, and also keeps spare room. Fast lookups are paid for in memory: roughly four to five times the list here. When memory matters more than speed, a sorted array with binary search, a bitset or a Bloom filter (Expert) can be the better choice.

Expert: attacks, modern designs and concurrency

At this level the questions change. Not “how does it work” but “what happens when someone hostile picks the keys”, “why are the fastest tables built the way they are”, and “what breaks when many threads share one”.

Hash flooding: turning O(1) into a denial of service

In December 2011, at the 28th Chaos Communication Congress (28C3), Alexander Klink and Julian Wälde showed that the hash tables inside PHP, Java, Python, Ruby and ASP.NET could be attacked with nothing but a crafted HTTP request. Web frameworks parse form fields and JSON into hash maps automatically. If the attacker picks field names that all collide, every insert walks an ever-longer chain, and building the table costs O(n²). A single request of a couple of megabytes could keep a server core busy for minutes. The underlying idea had been published by Crosby and Wallach in 2003; 28C3 showed how widely it still worked.

The Java version is beautiful. “Aa” and “BB” have the same String.hashCode (2112). And because the hash is computed left to right, any string built from those two blocks collides with any other string of the same length built from them: “AaAa”, “AaBB”, “BBAa” and “BBBB” all share one hash. With six blocks you get 2⁶ = 64 colliding keys; with sixteen, 65,536. Try it:

Try it

Attack a hash table

Insert 64 keys into a 16-bucket chained table (no resizing, so you can see the chains). Each insert compares the new key with every key already in its bucket.

Longest chain0

Comparisons to insert all 640

Cost of one more lookup0

Pick a set of keys.

You don’t need Java to see the cost. In Python, give every key the same hash and time the inserts:

import time

class Evil:
    """Every instance has the same hash, like keys an attacker crafted to collide."""
    def __init__(self, n):
        self.n = n
    def __hash__(self):
        return 42
    def __eq__(self, other):
        return self.n == other.n

for n in (1_000, 2_000, 4_000):
    start = time.perf_counter()
    d = {i: None for i in range(n)}
    normal = time.perf_counter() - start

    start = time.perf_counter()
    d = {Evil(i): None for i in range(n)}
    evil = time.perf_counter() - start
    print(f"{n:>5} keys: normal {normal * 1000:6.2f} ms   colliding {evil * 1000:8.1f} ms")
#  1000 keys: normal   0.04 ms   colliding     21.5 ms
#  2000 keys: normal   0.10 ms   colliding     86.6 ms
#  4000 keys: normal   0.19 ms   colliding    381.8 ms

Double the keys and the colliding version takes about four times as long: the n² signature. At 4,000 keys it’s already 2,000 times slower than normal.

The defenses came in layers:

  • Keyed hashing. If the hash mixes in a secret random number chosen when the process starts, an attacker can’t predict which keys collide. The standard choice became SipHash, designed by Jean-Philippe Aumasson and Daniel J. Bernstein in 2012 to be fast on short inputs and hard to collide without the key. Python randomized string hashing by default from 3.3, adopted SipHash in 3.4 (PEP 456), and uses the faster SipHash-1-3 variant today: sys.hash_info.algorithm reports siphash13 on 3.13. Rust’s default HashMap hasher is SipHash-1-3 for the same reason.
  • Bounding the worst case. Java’s treeified buckets (Advanced) cap a flooded bucket at O(log n).
  • Bounding the input. PHP added max_input_vars (default 1,000 form fields); servers like Tomcat cap parameter counts.

You can see Python’s randomization from the shell. Each process gets a new secret, unless you pin it with PYTHONHASHSEED:

$ python3 -c "print(hash('cat'))"
5265544399956307645
$ python3 -c "print(hash('cat'))"
8365802510015252210
$ PYTHONHASHSEED=0 python3 -c "print(hash('cat'))"
692052486964499043
$ PYTHONHASHSEED=0 python3 -c "print(hash('cat'))"
692052486964499043

That’s also why you must never persist hash() of a string, or use it to pick a shard: it changes every time the process restarts. Use a stable hash like hashlib.sha256 or zlib.crc32 when a hash has to mean the same thing tomorrow.

Robin Hood hashing: take from the rich

Linear probing’s problem is variance: most keys sit in their home slot, a few are pushed far away, and lookups for those are slow. Robin Hood hashing (Pedro Celis, 1986) evens that out. Every entry remembers how far it is from its home slot, its probe distance. When inserting, if you meet an entry that’s closer to home than you are, you take its slot and carry it onward instead. Rich entries (close to home) give up their place to poor ones (far from home).

The effect is that probe distances become nearly uniform, so the worst lookup is much closer to the average. It also enables an early exit on misses: if you’ve probed further than the entry you’re looking at is from its home, your key can’t be further along. And deletion doesn’t need tombstones: you remove the entry and shift the following entries back one slot until you hit one that’s already home (backward-shift deletion). Rust’s standard HashMap used Robin Hood hashing until version 1.36 in 2019.

Swiss tables: let the CPU do sixteen probes at once

The modern answer to “what’s the fastest general-purpose hash table” is the Swiss table, presented by Google engineers at CppCon 2017 and shipped as Abseil’s flat_hash_map. Rust’s standard HashMap switched to it (through the hashbrown crate) in 1.36, and Go’s maps moved to it in Go 1.24.

The insight is that on modern hardware, the expensive part of a lookup isn’t arithmetic; it’s cache misses from touching key memory. So a Swiss table keeps a separate array of one-byte control bytes, one per slot. The hash is split in two: most of the bits (H1) choose a group of 16 slots, and the remaining 7 bits (H2) are stored in the slot’s control byte. A control byte is either “empty”, “deleted” or “full, and here are 7 bits of my hash”.

A lookup loads the 16 control bytes of a group into one SIMD register and compares all of them against H2 with a single instruction. Out comes a bitmask of candidate slots, usually zero or one of them, and only those keys are actually compared. A miss usually touches no key memory at all: the group contains an “empty” byte and no H2 match, and the search is done. It’s open addressing, but probing 16 slots per step for the price of one.

Cuckoo hashing: a guaranteed two-slot lookup

Cuckoo hashing (Rasmus Pagh and Flemming Friche Rodler, 2001) gives every key exactly two possible homes, from two different hash functions, often in two separate tables. A lookup checks those two slots and nothing else: worst-case O(1), not just average. Insert puts the key in one of its homes; if that’s taken, it evicts the occupant (like a cuckoo chick pushing out an egg), which moves to its other home, possibly evicting someone else. If evictions go round in a cycle, the table rehashes with new functions. The price is a lower usable load (around 50% for two tables with one slot each, much higher with small buckets) and expensive, occasionally failing inserts. It shines where reads dominate and worst-case lookup time matters, and its fingerprint variant, the cuckoo filter, competes with Bloom filters.

Concurrency: what breaks when threads share a table

A hash table is a pile of invariants (counts, chains, the resize in progress) and concurrent writers can corrupt any of them. Java’s plain HashMap is famous for this: before Java 8, two threads resizing at once could turn a chain into a cycle, and a later get would loop forever at 100% CPU.

Java’s ConcurrentHashMap is the classic design for doing it properly. Java 7 used lock striping: the table was split into 16 segments, each with its own lock, so threads writing different segments didn’t block each other. Java 8 went finer: an insert into an empty bin uses a lock-free compare-and-swap, an insert into an occupied bin locks only that bin’s first node, reads take no lock at all, and the size is kept in a set of counters that threads update without contending. Resizing is done cooperatively, with threads helping move bins.

Python has a different story. In CPython with the GIL, a single dict operation like d[k] = v or d.get(k) happens atomically, so the dict itself can’t be corrupted. But counts[word] += 1 is three operations, read, add and write, and another thread can run between them and lose an update. Free-threaded CPython (3.13+, PEP 703) protects dicts with per-object locks internally, which keeps the dict consistent but still doesn’t make your compound operations atomic. Either way, guard read-modify-write with a lock:

import threading
from collections import defaultdict

counts = defaultdict(int)
lock = threading.Lock()

def record(word, times):
    for _ in range(times):
        with lock:                     # read-add-write as one step
            counts[word] += 1

threads = [threading.Thread(target=record, args=("hit", 100_000)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counts["hit"])
# 400000

Across processes and servers there’s no shared memory at all, so the shared hash map moves out of the process: Redis is, at heart, a networked hash map with atomic operations like INCR, HINCRBY and SET key value NX. You’ll see that in production below.

Memory: the price of speed

Every fast lookup above is paid for with spare slots and stored hashes. Measured on Python 3.13, a set of 100,000 short strings costs about 4 MiB for the table plus another 5 MiB for the string objects. A list of the same strings needs under 1 MiB for its pointers. When the set doesn’t fit (in a cache, in RAM, in every server), you trade exactness or speed for space. The next two structures are both built from hash functions, and both show up constantly in system design.

Structures built on hash maps

LRU cache. A cache with a size limit needs to evict the least recently used entry, and both “find entry” and “mark as recently used” have to be O(1). A hash map alone can find but doesn’t track order; a linked list tracks order but can’t find. Together they do both: the map points from key to a node in a doubly linked list ordered by recency.

hash map: key → node a b c c a b most recent evict next
The map finds the node in one lookup. A doubly linked list moves it to the front, or drops the tail, with a few pointer changes and no searching.

Python’s OrderedDict is exactly that pairing (and functools.lru_cache is implemented the same way in C), so an LRU cache is a few lines:

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.items = OrderedDict()            # a dict plus a doubly linked list

    def get(self, key):
        if key not in self.items:
            return None
        self.items.move_to_end(key)           # O(1): unlink and relink at the end
        return self.items[key]

    def put(self, key, value):
        self.items[key] = value
        self.items.move_to_end(key)
        if len(self.items) > self.capacity:
            self.items.popitem(last=False)    # O(1): drop the least recently used

cache = LRUCache(2)
cache.put("a", 1); cache.put("b", 2)
cache.get("a")                                # "a" is now the most recent
cache.put("c", 3)                             # evicts "b"
print(list(cache.items))
# ['a', 'c']

Bloom filter. Sometimes you only need “definitely not seen” or “probably seen”, for example “has this user possibly visited this URL before?” A Bloom filter answers that in a tiny fraction of a set’s memory. It’s an array of m bits and k hash functions. Adding an item sets the k bits its hashes point to; checking an item looks at those k bits. If any is 0, the item was never added. If all are 1, it probably was, but other items might have set those bits: a false positive. It never gives false negatives. The standard sizing formulas say about 9.6 bits per item buys a 1% false-positive rate, with 7 hash functions:

import hashlib, math

class BloomFilter:
    def __init__(self, expected, fp_rate):
        self.m = math.ceil(-expected * math.log(fp_rate) / math.log(2) ** 2)   # bits
        self.k = max(1, round(self.m / expected * math.log(2)))                 # hash functions
        self.bits = bytearray((self.m + 7) // 8)

    def _positions(self, item):
        digest = hashlib.sha256(item.encode()).digest()
        h1 = int.from_bytes(digest[:8], "big")
        h2 = int.from_bytes(digest[8:16], "big")
        return [(h1 + i * h2) % self.m for i in range(self.k)]   # double hashing

    def add(self, item):
        for p in self._positions(item):
            self.bits[p // 8] |= 1 << (p % 8)

    def __contains__(self, item):
        return all(self.bits[p // 8] & (1 << (p % 8)) for p in self._positions(item))

bf = BloomFilter(expected=100_000, fp_rate=0.01)
for i in range(100_000):
    bf.add(f"user-{i}")
print(bf.m, "bits =", bf.m // 8 // 1024, "KiB,", bf.k, "hash functions")
print("user-42" in bf)
false_hits = sum(f"stranger-{i}" in bf for i in range(100_000))
print(f"false positive rate: {false_hits / 100_000:.2%}")
# 958506 bits = 117 KiB, 7 hash functions
# True
# false positive rate: 1.03%

117 KiB instead of about 9 MiB for the set of the same 100,000 strings, at the cost of being wrong 1% of the time, and only in one direction. The two-hash trick in _positions (Kirsch and Mitzenmacher’s double hashing) gets k positions out of one digest. Databases use Bloom filters to skip disk reads for keys that aren’t in a file, and CDNs to avoid caching one-hit wonders.

Exercise · ExpertAn LRU cache without OrderedDict

Implement LRUCache(capacity) with get(key) (returns -1 if missing) and put(key, value), both O(1), using a plain dict and your own doubly linked list. This is a very common senior interview question, because it tests whether you understand why the combination works.

Show solution

Two sentinel nodes, head and tail, remove every empty-list special case. The dict maps key to node; the list runs from most recent (after head) to least recent (before tail).

class Node:
    __slots__ = ("key", "value", "prev", "next")
    def __init__(self, key=None, value=None):
        self.key, self.value, self.prev, self.next = key, value, None, None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.nodes = {}                          # key -> Node
        self.head, self.tail = Node(), Node()    # sentinels: no empty-list special cases
        self.head.next, self.tail.prev = self.tail, self.head

    def _unlink(self, node):
        node.prev.next, node.next.prev = node.next, node.prev

    def _push_front(self, node):
        node.prev, node.next = self.head, self.head.next
        self.head.next.prev = node
        self.head.next = node

    def get(self, key):
        node = self.nodes.get(key)
        if node is None:
            return -1
        self._unlink(node)
        self._push_front(node)                   # now the most recently used
        return node.value

    def put(self, key, value):
        if key in self.nodes:
            self._unlink(self.nodes[key])
        node = Node(key, value)
        self.nodes[key] = node
        self._push_front(node)
        if len(self.nodes) > self.capacity:
            lru = self.tail.prev                 # least recently used
            self._unlink(lru)
            del self.nodes[lru.key]

c = LRUCache(2)
c.put(1, 1); c.put(2, 2)
print(c.get(1))      # 1, and key 1 becomes most recent
c.put(3, 3)          # evicts key 2
print(c.get(2))      # -1
c.put(4, 4)          # evicts key 1
print(c.get(1), c.get(3), c.get(4))
# 1
# -1
# -1 3 4

The node stores its own key so eviction can delete the dict entry: given only the tail node, you need to know which key to remove. That detail is what interviewers watch for. Every operation is a dict lookup plus a constant number of pointer changes: O(1).

In production: hash maps in a multi-tenant backend

I’m the primary engineer of ezychat.io, a multi-tenant platform where AI answers customers on WhatsApp, Instagram, Messenger and web chat. It’s Python (FastAPI and Celery), PostgreSQL and Redis. Hash maps are everywhere in it, but a few uses carry real design decisions, and each maps back to a level above. These are trimmed excerpts from the codebase.

Load once, index by ID, diff with sets

When a user schedules a social post to several accounts, the API receives a list of account IDs. The naive version queries each account in a loop: one database round trip per ID. Instead, the code loads them all in one query, indexes them in a dictionary, and uses a set difference to find any IDs that didn’t come back:

async def _load_org_accounts_by_id(session, org_id, account_ids):
    if not account_ids:
        return {}
    unique_ids = set(account_ids)
    rows = (await session.execute(
        select(SocialAccount).where(
            SocialAccount.org_id == org_id, SocialAccount.id.in_(unique_ids)
        )
    )).scalars().all()
    found = {row.id: row for row in rows}
    missing = unique_ids - found.keys()
    if missing:
        raise HTTPException(422, {"error_code": "unknown_target_account", ...})
    return found

Three hash structures in eight lines. set(account_ids) drops duplicates before they reach the database. {row.id: row} turns the result into O(1) lookups for the rest of the request. And unique_ids - found.keys() (dict key views support set operations) answers “which IDs were bad?” in one expression. The security detail is the org_id filter: an account belonging to another tenant simply isn’t returned, so it shows up in missing and the request is rejected, without ever revealing that the account exists.

A dictionary keyed by event loop

Python’s async Redis client has a subtle rule: a connection belongs to the event loop that opened it. The API runs on one long-lived loop, but Celery task bodies each run their own short-lived loop. A single process-wide client, created on one loop, silently breaks on the next one, and because the Redis paths are written to fail open (a cache miss rather than an error), nothing visibly fails; caches and the circuit breaker just stop working.

The fix is a hash map whose keys are event loop objects:

# One client per event loop: an async Redis connection belongs to the loop
# that opened it, and Celery task bodies each run on their own short-lived loop.
_redis_by_loop: "WeakKeyDictionary[asyncio.AbstractEventLoop, Redis]" = WeakKeyDictionary()

def get_redis() -> Redis:
    loop = asyncio.get_running_loop()
    client = _redis_by_loop.get(loop)
    if client is None:
        client = _new_client()
        _redis_by_loop[loop] = client
    return client

Two hash-map ideas meet here. An event loop has no meaningful value equality, so it’s hashed by identity: each loop object is its own key. And WeakKeyDictionary holds its keys weakly. When a task’s throwaway loop is garbage collected, its entry disappears instead of keeping a dead loop and its client alive forever. An ordinary dict here would be a slow memory leak.

Tuples as composite keys

Publishing validation collects issues from several checks, which can repeat. Deduplicating them uses exactly the Intermediate rule about composite keys:

def _dedupe(issues):
    seen: set[tuple[str, str, str]] = set()
    result = []
    for issue in issues:
        key = (issue.code, issue.message, issue.severity)
        if key in seen:
            continue
        seen.add(key)
        result.append(issue)
    return result

The Issue objects themselves aren’t used as keys; a tuple of the fields that define “the same issue” is. Choosing those fields is the design: add severity to the key and a warning and an error with the same message both survive.

Redis: one hash map shared by every server

The API runs as several containers, so per-process dictionaries can’t hold anything that must be shared, like rate-limit counters. Those live in Redis, which is a hash map reachable over the network. The rate limiter’s keys show how much design lives in a key:

current_key = f"rl:{bucket}:{identity}:{window}"
previous_key = f"rl:{bucket}:{identity}:{window - 1}"

bucket names the limit (login attempts, API calls per organization), identity is who’s being counted (an IP address, a user ID, an organization ID) and window is the time slot. Every server computes the same key for the same caller in the same minute, so they all increment the same counter. The full algorithm, a sliding-window estimate computed atomically inside Redis, is in the rate limiter article.

“Send once” latches, and what belongs in the key

SET key value NX sets a key only if it doesn’t exist and reports whether it did: a distributed if key not in seen: seen.add(key) in one atomic step. ezychat uses it as a latch for “do this at most once”. The billing worker notifies an organization when it reaches its plan’s limit of monthly active customers, and the latch stops the same notice going out again on every run:

# The limit is part of the latch, not just the period. Keyed on the period
# alone, an org that hit 200, bought an MAC pack, and then hit the new 250
# in the same period would get silence: the latch was already set, so the
# second time they lost auto-replies nobody would be told.
if not redis.set(
    f"usagelimit:{org_id}:{period_key}:mac:{limit}", 1, nx=True, ex=metering.KEY_TTL_SECONDS
):
    return None

The comment is the lesson. The first version keyed the latch on the organization and the billing period. That’s “notify once per period”, which sounds right until a customer raises their limit mid-period: the second time they hit it, the latch was already set, and they lost auto-replies with nobody told. Adding the limit to the key changes the meaning to “notify once per period per ceiling”. What you put in a key decides what counts as the same thing. That’s the hash contract from Advanced, applied to business logic. The ex= expiry matters too: the latch cleans itself up instead of growing forever.

The database is a set too

The same “have I seen this?” question appears at the storage layer. Messaging providers retry webhooks, so the same inbound message can arrive twice. Each message has the provider’s own ID, and the insert is deduplicated on (org_id, channel_msg_id):

stmt = (
    pg_insert(Message)
    .values(org_id=org_id, channel_msg_id=channel_msg_id, ...)
    .on_conflict_do_nothing(
        index_elements=["org_id", "channel_msg_id"],
        index_where=Message.channel_msg_id.isnot(None),
    )
    .returning(Message.id)
)

A unique index plus ON CONFLICT DO NOTHING is set membership that survives restarts: a retried delivery returns no new row, and the pipeline stops there. (PostgreSQL’s unique indexes are B-trees, not hash tables, but the semantics are the set’s: one entry per key.) The key again carries the design: including org_id means two tenants can never collide, even if two providers happened to issue the same message ID.

What production adds to the textbook

  • The key is the design. In every example above, the interesting decision was what goes into the key: which tenant, which window, which ceiling, which fields.
  • Shared state moves out of the process. A dict is per process. Anything several servers must agree on (counters, latches, dedupe) needs Redis or the database, with an atomic operation, not a read-then-write.
  • Give entries a lifetime. Weak keys, expiries and unique indexes all answer “when does this entry go away?” A hash map that only grows is a memory leak with good performance.
  • Batch, then index. Loading many rows in one query and building a dict beats N lookups every time it’s possible.

Cheat sheet

Topic What to remember
Core idea slot = hash(key) % capacity; then compare keys with ==
Python dict for key → value, set for membership, Counter for counting, defaultdict(list) for grouping
Cost O(1) average for insert, lookup, delete; O(n) worst case when keys collide
Load factor α = keys / slots; resize (double) past ~0.75 (Java) or ~2/3 (CPython dict)
Resizing Amortized O(1) because capacity grows by a factor; one insert can still pause
Collisions Chaining (lists per slot) or open addressing (probe other slots)
Deleting Open addressing needs tombstones, or Robin Hood backward shift
Keys Must be hashable and must not change while stored; equal keys need equal hashes
CPython dict Compact: index array + dense entries; insertion order guaranteed since 3.7
Attacks Hash flooding forces O(n²); defended by keyed hashes (SipHash), treeified buckets, input caps
Never Persist hash() of a string: it changes per process. Use hashlib for stable hashes
Modern designs Swiss tables (SIMD over control bytes), Robin Hood, cuckoo (two-slot lookups)
Concurrency Lock read-modify-write; across servers use Redis atomics (INCR, SET NX)
Built on top LRU cache = dict + doubly linked list; Bloom filter = bits + k hashes
Interview cue “seen before”, “count”, “group by”, “find a pair” → hash map

Check yourself

  1. A nightly job checks 200,000 orders against 50,000 banned user IDs, kept in a list. Roughly how many comparisons can it do in the worst case?

    banned = load_ids()  # a list
    for o in orders:
        if o.user in banned:
            flag(o)

    Answer: Up to 10 billion. Each "in" check walks up to 50,000 IDs and runs 200,000 times: 200,000 × 50,000 = 10 billion. Converting the list to a set once makes it about 250,000 operations.

  2. Why does {[1, 2]: "a"} raise TypeError while {(1, 2): "a"} works?

    Answer: A list can change after being stored, so it has no stable hash. A key must keep the same hash while it is stored. Lists are mutable, so Python gives them no hash; tuples are immutable and can be keys.

  3. A hash table doubles its capacity whenever it gets 75% full. What is the cost of inserting n keys in total?

    Answer: O(n), so O(1) amortized per insert. The copies form a geometric series (6 + 12 + 24 + …) that sums to less than 2n, so n inserts cost less than 3n steps in total.

  4. In a linear-probing table, you delete a key by setting its slot back to empty. What breaks?

    Answer: Keys stored after it in the same probe run can no longer be found. Lookups stop at the first empty slot. Emptying a slot in the middle of a run cuts it, hiding later keys. That is why open addressing uses tombstones.

  5. What does {1: "int", True: "bool", 1.0: "float"} evaluate to in Python?

    Answer: {1: 'float'}. 1, True and 1.0 are equal and hash equally, so they are one key. The dict keeps the first key object and the last value.

  6. What made the 2011 hash flooding attack possible?

    Answer: Attackers could predict which keys collide, because the hash had no secret. With a public, unkeyed hash, an attacker can craft many colliding keys and make building the table O(n²). Keyed hashes like SipHash hide which keys collide.

  7. Why does an LRU cache need both a hash map and a doubly linked list?

    Answer: The map finds an entry in O(1); the list reorders and evicts in O(1). A map alone cannot track recency order cheaply, and a list alone cannot find an entry without searching. Together, get and put are both O(1).