Hash Tables
How a dictionary finds a key without searching: hash functions, buckets, collisions, load factor and resizing. Build one from scratch, measure what collisions cost, and learn the patterns (counting, grouping, complement lookup) where hash tables dominate.
Finding something without looking everywhere
You run a login service with ten million accounts. A request arrives with the username "maya_k". A list would make you scan the accounts one by one: ten million comparisons in the worst case, for every login. A sorted list with binary search brings that down to about twenty-four comparisons, but now every new signup must be inserted in the middle and everything after it shifted.
There is a better idea, and it is the one behind Python dictionaries, JavaScript objects and maps, Java's HashMap and the lookup tables inside databases and compilers. Instead of searching for the key, compute where it lives. Turn the key into a number, use the number as a position in an array, and go straight there. Looking up, inserting and deleting take, on average, a constant number of steps, regardless of how many items are stored.
A data structure that stores key-value pairs in an array of buckets. A hash function converts each key into an integer, which is reduced to a bucket index; the pair is stored at (or near) that index. Average-case lookup, insert and delete are O(1).
The hash function: key to index
A hash function maps a key of any size to an integer. For a hash table it must have three properties. It must be deterministic: the same key always gives the same number, or you could never find your data again. It must be fast, since it runs on every operation. And it should spread keys evenly, so that keys do not crowd into the same few buckets.
The integer is usually huge, so the table reduces it to a valid slot with the remainder operator: index = hash(key) % number_of_buckets. Below is a deliberately simple string hash. It walks through the characters, mixing each into a running total. Real libraries use stronger functions (Python randomises string hashes per process, as a defence against attackers who craft colliding keys), but the shape of the idea is identical.
1def simple_hash(text):2 value = 53813 for ch in text:4 value = (value * 33 + ord(ch)) % 10000035 return value67buckets = 88for word in ["apple", "banana", "cherry", "date", "elderberry", "fig", "grape"]:9 h = simple_hash(word)10 print(word.ljust(11), "hash =", str(h).rjust(6), "-> bucket", h % buckets)
apple hash = 102529 -> bucket 1 banana hash = 646504 -> bucket 0 cherry hash = 778439 -> bucket 7 date hash = 125008 -> bucket 0 elderberry hash = 452975 -> bucket 7 fig hash = 491064 -> bucket 0 grape hash = 273705 -> bucket 1
Collisions are guaranteed, not unlucky
With 8 buckets and 9 keys, two keys must share a bucket, by the pigeonhole principle. Even with far more buckets than keys, the birthday paradox says collisions arrive sooner than intuition suggests: in a group of just 23 people, two share a birthday with probability above one half. So every hash table must have a plan for collisions. There are two classic plans.
Separate chaining keeps a small list in each bucket. Colliding keys are appended to the same list, and a lookup hashes to the bucket and scans its short list. Open addressing stores everything in the array itself: if the preferred slot is taken, probe the next slot (linear probing), or another computed one, until a free slot appears. Python's dictionary uses open addressing with a smarter probe sequence.
Both work well as long as the lists or probe sequences stay short, and that depends on one number.
bucket 0: -> ("ann", 31) -> ("zoe", 8)
bucket 1: (empty)
bucket 2: -> ("bob", 25)
bucket 3: (empty)
bucket 4: -> ("cy", 40) -> ("li", 19) -> ("ed", 52)
bucket 5: (empty)Load factor and resizing
The number that matters is the load factor: items stored divided by buckets. With a low load factor, chains are short and probes are brief, so operations are fast. As it climbs, chains lengthen and performance slides toward the O(n) of a list.
So a hash table watches its own load. When the load factor crosses a threshold (around 0.7 to 1.0 is typical, depending on the design), it allocates a larger array, usually double, and rehashes every key into the new array, since the bucket index depends on the array size. A resize is expensive, O(n), but it happens rarely: doubling means the total cost of all resizes across n inserts is O(n), so each insert is amortised O(1).
Build one yourself and the whole mechanism stops being magic. The class below uses chaining and doubles when the load factor exceeds 0.75. It also counts probes, the number of key comparisons per operation, so you can measure the effect of load instead of taking it on faith.
1class HashTable:2 def __init__(self, buckets=4):3 self.buckets = [[] for _ in range(buckets)]4 self.size = 05 self.resizes = 067 def _index(self, key, buckets):8 h = 53819 for ch in str(key):10 h = (h * 33 + ord(ch)) % 100000311 return h % len(buckets)1213 def put(self, key, value):14 chain = self.buckets[self._index(key, self.buckets)]15 for i, (k, _) in enumerate(chain):16 if k == key:17 chain[i] = (key, value) # key exists: update18 return19 chain.append((key, value))20 self.size += 121 if self.size / len(self.buckets) > 0.75:22 self._resize()2324 def get(self, key):25 chain = self.buckets[self._index(key, self.buckets)]26 comparisons = 027 for k, v in chain:28 comparisons += 129 if k == key:30 return v, comparisons31 return None, comparisons3233 def _resize(self):34 new = [[] for _ in range(len(self.buckets) * 2)]35 for chain in self.buckets:36 for k, v in chain:37 new[self._index(k, new)].append((k, v)) # index depends on table size38 self.buckets = new39 self.resizes += 14041table = HashTable()42for n in range(1, 101):43 table.put("user" + str(n), n)4445print("items:", table.size, "| buckets:", len(table.buckets), "| resizes:", table.resizes)46print("load factor:", round(table.size / len(table.buckets), 2))47print("longest chain:", max(len(c) for c in table.buckets))48print("get user42:", table.get("user42"))49print("get missing:", table.get("nobody"))
items: 100 | buckets: 256 | resizes: 6 load factor: 0.39 longest chain: 2 get user42: (42, 1) get missing: (None, 0)
Hash tables in everyday Python (and what can be a key)
You rarely build one, because dict and set are hash tables. The rule for keys follows directly from the mechanism. A key must be hashable: its hash must never change while it is in the table, otherwise the stored position and the computed position diverge and the entry becomes unfindable. Immutable values (numbers, strings, tuples of hashables) qualify. A list or a dict does not, because its contents can change, so Python refuses it with a TypeError.
Two more consequences are worth knowing. Iteration order is not sorted order: a dict remembers insertion order (a language guarantee since Python 3.7) but that is a courtesy, not a sorted structure. And keys are compared with equality, so 1 and 1.0 and True are the same key.
1scores = {}2scores[("lat", "lon")] = "tuple ok"3scores["text"] = "string ok"4scores[1] = "one"5scores[1.0] = "overwrote one" # 1 == 1.0, so same key6scores[True] = "overwrote again" # True == 1 too7print(scores)89try:10 scores[[1, 2]] = "list"11except TypeError as error:12 print("TypeError:", error)1314# Making a mutable collection usable as a key: freeze it15group = frozenset(["a", "b"])16print({group: "frozenset is hashable"}[frozenset(["b", "a"])])
{('lat', 'lon'): 'tuple ok', 'text': 'string ok', 1: 'overwrote again'}
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
frozenset is hashableThree patterns that make hash tables the default tool
Counting. Walk the data once and add one to a counter per key. Word frequencies, character counts and votes all become a single linear pass.
Grouping. Use a canonical form as the key. All words that are anagrams of each other share the same sorted letters, so sorting the letters gives you a key under which to group them.
Complement lookup. To find two numbers that sum to a target, store each number as you pass it; for each new number, ask whether target minus that number is already in the table. A quadratic search over pairs becomes one linear pass. This is the idea behind the famous first interview problem, and it generalises: whenever a problem asks "have I already seen something related to this?", a hash table answers in constant time.
1from collections import defaultdict23# 1. Counting4text = "the cat and the hat and the bat"5counts = {}6for word in text.split():7 counts[word] = counts.get(word, 0) + 18print(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))910# 2. Grouping anagrams by a canonical key11groups = defaultdict(list)12for word in ["listen", "silent", "enlist", "google", "gogole", "cat", "act"]:13 groups["".join(sorted(word))].append(word)14print(sorted(groups.values()))1516# 3. Complement lookup: two numbers summing to a target, in one pass17def two_sum(numbers, target):18 seen = {} # value -> index19 for i, n in enumerate(numbers):20 if target - n in seen:21 return [seen[target - n], i]22 seen[n] = i23 return None2425print(two_sum([2, 7, 11, 15], 9))26print(two_sum([3, 2, 4], 6))27print(two_sum([1, 2, 3], 100))
[('the', 3), ('and', 2), ('bat', 1), ('cat', 1), ('hat', 1)]
[['cat', 'act'], ['google', 'gogole'], ['listen', 'silent', 'enlist']]
[0, 1]
[1, 2]
NoneCosts and limits
The O(1) claim is an average. If every key lands in one bucket, a hash table degrades into a single linked list and each operation costs O(n). Good hash functions and resizing make that vanishingly unlikely for ordinary data, and randomised hashing makes it hard for an attacker to force. Resizing also means a single insert occasionally costs O(n), which matters in real-time systems with strict latency limits.
What a hash table cannot do is anything involving order. Finding the smallest key, the next key above a value or all keys in a range requires scanning every bucket. For those, use a balanced search tree or a sorted array. Memory is another trade: hash tables keep spare empty buckets and therefore use more space than a tightly packed array of the same data.
Hash table versus alternatives
| Operation | Hash table (average) | Sorted array | Unsorted list |
|---|---|---|---|
| Lookup by key | O(1) | O(log n) | O(n) |
| Insert | O(1) amortised | O(n) | O(1) at the end |
| Delete | O(1) | O(n) | O(n) |
| Min, max, range query | O(n) | O(1) / O(log n) | O(n) |
| Iterate in sorted order | O(n log n) after sorting | O(n) | O(n log n) |
Check your understanding
Think about the mechanism rather than the vocabulary.
A hash table has 16 buckets and holds 12 items. A resize is triggered at load factor 0.75. What happens on the next insert, and why must every key be moved?
Quick Syntax & Reference Sheet
| Concept / Rule | Syntax / Usage |
|---|---|
| Bucket index | index = hash(key) % len(buckets) |
| Load factor | items / buckets; resize (usually double) when it passes about 0.75 |
| Count with a dict | counts[k] = counts.get(k, 0) + 1 |
| Complement lookup | if target - n in seen: return ...
seen[n] = i |
Common Pitfalls & Developer Mistakes
Using a mutable object as a key
Why it happens: Its hash can change, which breaks lookup; Python raises TypeError for lists, dicts and sets.
Assuming hash tables keep keys in sorted order
Why it happens: Position is determined by the hash, not the value. Python dicts remember insertion order only.
Modifying a dict while iterating over it
Why it happens: Changing the size during iteration invalidates the iteration and raises RuntimeError.