Skip to main content
Data Structures•Last Updated : 23 Oct 2026
~50 min read

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.

Definition: Hash table

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.

PythonA small deterministic string hash and bucket indexes
1def simple_hash(text):
2 value = 5381
3 for ch in text:
4 value = (value * 33 + ord(ch)) % 1000003
5 return value
6
7buckets = 8
8for 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)
Output
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.

Separate chaining
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)
Keys that hash to the same index share a chain. Lookup cost is the length of one chain, not the size of the table.

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.

PythonA hash table with chaining and automatic resizing
1class HashTable:
2 def __init__(self, buckets=4):
3 self.buckets = [[] for _ in range(buckets)]
4 self.size = 0
5 self.resizes = 0
6
7 def _index(self, key, buckets):
8 h = 5381
9 for ch in str(key):
10 h = (h * 33 + ord(ch)) % 1000003
11 return h % len(buckets)
12
13 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: update
18 return
19 chain.append((key, value))
20 self.size += 1
21 if self.size / len(self.buckets) > 0.75:
22 self._resize()
23
24 def get(self, key):
25 chain = self.buckets[self._index(key, self.buckets)]
26 comparisons = 0
27 for k, v in chain:
28 comparisons += 1
29 if k == key:
30 return v, comparisons
31 return None, comparisons
32
33 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 size
38 self.buckets = new
39 self.resizes += 1
40
41table = HashTable()
42for n in range(1, 101):
43 table.put("user" + str(n), n)
44
45print("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"))
Output
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.

PythonHashable keys, unhashable keys, and equal keys
1scores = {}
2scores[("lat", "lon")] = "tuple ok"
3scores["text"] = "string ok"
4scores[1] = "one"
5scores[1.0] = "overwrote one" # 1 == 1.0, so same key
6scores[True] = "overwrote again" # True == 1 too
7print(scores)
8
9try:
10 scores[[1, 2]] = "list"
11except TypeError as error:
12 print("TypeError:", error)
13
14# Making a mutable collection usable as a key: freeze it
15group = frozenset(["a", "b"])
16print({group: "frozenset is hashable"}[frozenset(["b", "a"])])
Output
{('lat', 'lon'): 'tuple ok', 'text': 'string ok', 1: 'overwrote again'}
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
frozenset is hashable

Three 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.

PythonCounting, grouping and complement lookup
1from collections import defaultdict
2
3# 1. Counting
4text = "the cat and the hat and the bat"
5counts = {}
6for word in text.split():
7 counts[word] = counts.get(word, 0) + 1
8print(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
9
10# 2. Grouping anagrams by a canonical key
11groups = defaultdict(list)
12for word in ["listen", "silent", "enlist", "google", "gogole", "cat", "act"]:
13 groups["".join(sorted(word))].append(word)
14print(sorted(groups.values()))
15
16# 3. Complement lookup: two numbers summing to a target, in one pass
17def two_sum(numbers, target):
18 seen = {} # value -> index
19 for i, n in enumerate(numbers):
20 if target - n in seen:
21 return [seen[target - n], i]
22 seen[n] = i
23 return None
24
25print(two_sum([2, 7, 11, 15], 9))
26print(two_sum([3, 2, 4], 6))
27print(two_sum([1, 2, 3], 100))
Output
[('the', 3), ('and', 2), ('bat', 1), ('cat', 1), ('hat', 1)]
[['cat', 'act'], ['google', 'gogole'], ['listen', 'silent', 'enlist']]
[0, 1]
[1, 2]
None

Costs 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

OperationHash table (average)Sorted arrayUnsorted list
Lookup by keyO(1)O(log n)O(n)
InsertO(1) amortisedO(n)O(1) at the end
DeleteO(1)O(n)O(n)
Min, max, range queryO(n)O(1) / O(log n)O(n)
Iterate in sorted orderO(n log n) after sortingO(n)O(n log n)

Check your understanding

Think about the mechanism rather than the vocabulary.

Suggested Quiz
Question 1 of 4

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 / RuleSyntax / Usage
Bucket indexindex = hash(key) % len(buckets)
Load factoritems / buckets; resize (usually double) when it passes about 0.75
Count with a dictcounts[k] = counts.get(k, 0) + 1
Complement lookupif target - n in seen: return ... seen[n] = i

Common Pitfalls & Developer Mistakes

✕ Mistake 1:

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.

Correction: Convert to a tuple or frozenset first.
✕ Mistake 2:

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.

Correction: Sort the keys explicitly when order matters, or use a structure designed for ordered keys.
✕ Mistake 3:

Modifying a dict while iterating over it

Why it happens: Changing the size during iteration invalidates the iteration and raises RuntimeError.

Correction: Iterate over list(d.items()) or collect changes and apply them afterwards.

Frequently Asked Questions

Is a hash table lookup really O(1)?

On average, yes, provided the hash function spreads keys well and the table resizes to keep the load factor bounded. The worst case is O(n) when many keys collide, which is why implementations use good hash functions and resize, and why O(1) is stated as average or amortised.

What is the difference between a dict and a set in Python?

Both are hash tables. A dict stores key-value pairs; a set stores only keys. Membership tests (x in s) are O(1) on average for both, versus O(n) for a list.
Article Tags:#Hash Table#Hash Map#Dictionary#Collisions#Load Factor#Hashing
Was this lesson clear and helpful?

Discussion (0)

Sign in to ask questions and reply.
Loading discussion
Discuss in community