Internals · B+ Treeshash indexbucketcollisionoverflow pagelinear hashing

Hash Index Internals

A hash index skips the tree entirely: hash the key, take a bucket number, read that bucket, find the record reference. One page for equality, no matter the size. The price is that the hash destroys order — no ranges, no prefixes, no ORDER BY — and that buckets fill up: collisions chain into overflow pages and growth means rehashing.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    Some lookups are equality and only equality — a session token, a UUID, a cache key. A B+ tree spends 3–4 page reads keeping an order those queries never use.

  2. Naive solution

    Compute h = hash(key) and store the record reference in slot h mod B of an array of B buckets. A lookup is one hash and one page read.

  3. Why it breaks

    Two keys can hash to the same bucket, and with B fixed, buckets overflow as the table grows. Fix B too small and every bucket is a long chain; fix it too large and the index is mostly empty pages.

  4. Better idea

    Let a bucket spill into a chained overflow page when it fills, and grow the bucket count gradually — split one bucket at a time (linear hashing) or double a small directory and split only the bucket that overflowed (extendible hashing) — so growth never rehashes everything at once.

  5. Internal mechanism

    Bucket pages hold (hash, record reference) pairs; a lookup reads one bucket page plus any overflow pages on its chain; a split rehashes one bucket's entries into two using one more bit of the hash.

  6. Trade-offs

    Equality in O(1) pages and a smaller index than a B+ tree for wide keys. But no ordering: ranges, prefixes, sorting, MIN/MAX all need a full scan, and a skewed hash or a bad load factor turns O(1) into a chain walk.

  7. Real database

    PostgreSQL hash indexes (WAL-logged since 10, rarely worth it over btree), InnoDB's adaptive hash index built automatically on top of hot B+ tree pages, and every in-memory key-value store.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

Compute the address instead of searching for it

A hash index does not search. It computes: run the key through a hash function, take the result modulo the number of buckets, and that number is where the record reference lives. One page read for any table size. It is the Hash Table from DSA with buckets stored as pages.

It can answer only one question: "is there a row with exactly this key?" Everything involving order — greater than, between, starts with, sorted — is invisible to it.

hash(key) → bucket → record reference

The index is an array of bucket pages. To insert a key: compute a 32- or 64-bit hash, take it modulo the number of buckets, append (hash, record reference) to that bucket page. To look a key up: compute the same bucket, read the page, compare hashes (and, on a hash match, keys) until one matches or the chain ends. There is no root, no descent and no order — the address is computed, not searched.

The entry stores the hash and the locator, not necessarily the key. That makes hash indexes compact for wide keys (a 200-byte URL becomes 8 bytes) at the cost of a heap check to confirm a match, which is exactly the trade PostgreSQL makes.

A hash index with 8 buckets (educational: integer keys, fnv1a hash)
key 17  →  fnv1a("k17") = 0x3a7f21c4  →  mod 8 = 4  →  BUCKET 4
key 42  →  fnv1a("k42") = 0x9c0e5d14  →  mod 8 = 4  →  BUCKET 4   ← collision: chained
key 91  →  fnv1a("k91") = 0x51b3e0a1  →  mod 8 = 1  →  BUCKET 1

BUCKET 0  [ ]
BUCKET 1  [ (0x51b3e0a1 → heap(11,3)) ]
BUCKET 2  [ (0x…       → heap(1,0)) ]
BUCKET 3  [ ]
BUCKET 4  [ (0x3a7f21c4 → heap(2,1))  (0x9c0e5d14 → heap(5,2)) ]  → overflow page ∅
...

FIND 42:   hash → bucket 4 → compare 0x3a7f21c4 ≠, 0x9c0e5d14 = → heap(5,2)     1 page, 2 compares
FIND 60:   hash → bucket 2 → compare 1 entry, no match → not present               1 page, 1 compare
RANGE 10..20:  no bucket to start from → read ALL 8 buckets, test every entry, sort  8 pages

Equality only: what the hash destroys

A good hash function scatters neighbouring keys as far apart as possible — that is the point of it, and it is why k BETWEEN 10 AND 20, k > 100, email LIKE 'a%', ORDER BY k, MIN(k) cannot use the index. The only correct way to answer them is to read every bucket and sort the survivors, which is a full scan of the index plus a sort: never better than a sequential scan of the table, usually worse.

This also rules out prefix use of composite keys. A hash over (country, city) says nothing about country alone; the whole key must be supplied, exactly. A B+ tree on the same columns answers country = ? with a range over the leftmost prefix; see Composite Indexes and the Leftmost-Prefix Rule.

Hash index vs B+ tree index
QuestionHash indexB+ tree
k = ?yes — 1 bucket pageyes — height pages (3–4)
k BETWEEN a AND bno — scan every bucketyes — descend + leaf walk
ORDER BY k, MIN/MAXnoyes — leaf chain
prefix: k LIKE 'ab%', leftmost columnsnoyes
Entry size for wide keyssmall — hash + locatorfull key + locator
Height / lookups per probe1 (+ overflow chain)log_fanout n
Growthsplit buckets: linear / extendible hashingpage splits, local
Unique constraintsPostgreSQL: not supportedyes

Collisions: chaining and overflow pages

With B buckets and n entries, the expected chain length is n/B — the load factor — and the longest chain is noticeably worse. In memory, collisions are handled by Separate Chaining (a linked list per bucket) or Open Addressing (probe the next slot). On disk, chaining is the only sensible option: a full bucket page links to an overflow page, and a lookup on that bucket reads both. The overflow chain is where a hash index's O(1) quietly becomes O(chain).

Two things make chains long: a load factor allowed to climb past ~1–2 without adding buckets, and a hash function that does not scatter the actual keys — sequential integers hashed with a weak function, or a modulus that shares factors with the data. Engines use strong, cheap hashes (PostgreSQL's hash_any, murmur-style mixing) and grow buckets before the load factor gets away.

Resizing: linear and extendible hashing

Doubling B changes hash mod B for roughly half the keys, and moving half the index in one operation is an outage at scale. Linear hashing grows one bucket at a time: it keeps a split pointer s and hashes with modulus 2B for buckets below s, B for the rest; each overflow event splits bucket s into s and s + B and advances the pointer, so no single insert pays more than one bucket rewrite. Extendible hashing keeps a directory of 2^d pointers into bucket pages; a full bucket splits using one more bit of the hash, and the directory doubles only when that bucket already used all d bits — the directory is small (pointers only) and the doubling touches no data.

Both keep growth incremental. The interactive shows the naive version deliberately — press Resize and count the highlighted keys that moved — because the moved set is the cost the two schemes exist to spread out.

Extendible hashing: split one bucket, double a small directory
global depth d = 2   directory: 00 → B0   01 → B1   10 → B2   11 → B3

insert into B2 (local depth 2, full):
  split B2 into B2 (…10 → keys whose next hash bit is 0) and B4 (…110 → bit 1)
  B2's local depth becomes 3 > global 2 → directory doubles to d = 3
  000 → B0  001 → B1  010 → B2  011 → B3  100 → B0  101 → B1  110 → B4  111 → B3

moved: only the entries of B2. B0, B1, B3 are untouched; the directory is pointers only.

PostgreSQL hash indexes

PostgreSQL implementation

CREATE INDEX … USING hash (col) builds a hash index whose entries are the 32-bit hash and the TID — never the key — so a match always re-checks the heap tuple. Bucket pages are allocated in power-of-two batches with linear-hashing-style incremental splits; overflow pages and bitmap pages manage chains. Before version 10 the index was not WAL-logged, so it was corrupt after a crash and absent on replicas, which earned it a warning in the manual and a reputation it has not shed.

Since 10 it is crash-safe and replicated. It is still rarely the right choice: it cannot enforce UNIQUE, cannot support multi-column keys, cannot answer ranges or ordering, and a btree on the same column is usually within a page read of it. The realistic cases are equality lookups on very wide keys (long URLs, large JSON paths) where the btree entry would be large and the tree tall.

The plan changes from index to scan the moment the predicate is not equality.
1CREATE INDEX sessions_token_hash ON sessions USING hash (token);
2
3EXPLAIN SELECT user_id FROM sessions WHERE token = 'e3b0c442…';
4-- Index Scan using sessions_token_hash on sessions (cost=0.00..8.02 rows=1 width=8)
5-- Index Cond: (token = 'e3b0c442…'::text)
6
7EXPLAIN SELECT user_id FROM sessions WHERE token > 'e3';
8-- Seq Scan on sessions -- the hash index cannot help: no order

InnoDB: the adaptive hash index

MySQL / InnoDB implementation

InnoDB has no user-declared hash index. Instead the adaptive hash index (AHI) watches B+ tree lookups; when the same index and key prefix are probed repeatedly, it builds an in-memory hash from that key pattern directly to the leaf page record, so subsequent equality lookups skip the descent. It lives in the buffer pool, is rebuilt after restart, is partitioned to reduce contention, and can be switched off (innodb_adaptive_hash_index = OFF) when the workload is scan-heavy and the maintenance overhead shows up in profiles.

It is the same insight as the hash index — equality does not need order — applied as a cache over the structure that does, which is why it costs nothing in range capability.

Key points

  • A hash index computes the bucket from the key: one page read for equality, independent of table size.
  • The hash destroys order: ranges, prefixes, ORDER BY and MIN/MAX require reading every bucket.
  • Collisions chain within a bucket and spill into overflow pages; load factor decides whether O(1) holds.
  • Growth is incremental in real engines — linear hashing splits one bucket per step, extendible hashing doubles a pointer directory — because rehashing everything at once is an outage.
  • PostgreSQL hash indexes are crash-safe since version 10 and still rarely beat btree; InnoDB's adaptive hash index is an automatic in-memory cache over the B+ tree.

Hash index internals

Hash index: hash(key) → bucket → record
No ordering anywhere. A lookup is one hash and one bucket read; anything that needs neighbours has to scan every bucket.
fnv1a("k27") = 0x2eca972d → mod 8 = bucket 5
b0
42
↳ 91
↳ 33
↳ 64
b1
12
b2
17
b3
b4
b5
5
b6
8
b7
Buckets
8
Entries
8
Load factor
1.00
Longest chain
4
Last op: buckets read
Educational simulation — 8 buckets and integer keys; a real hash index stores buckets as pages and chains overflow into overflow pages.
Insert a few keys and watch collisions chain. Then Find one (one bucket), try Range 10..20 (every bucket), and Resize to see how many entries move.
What a bucket holds
bucket page: [hash₃₂, heap(page,slot)] × n · overflow → page id

When to use — and when not

Use it when
  • This structure fits equality-only lookups on wide keys — tokens, UUID strings, URLs — where the B+ tree entry would be large and no query will ever range or sort on the column.
  • In-memory key-value stores and caches, where ordering is not a feature.
  • As an automatic accelerator over a B+ tree for hot repeated point lookups (InnoDB AHI).
Avoid it when
  • This structure is wrong whenever any query needs order on the key — ranges, prefixes, sorting, MIN/MAX — or a unique constraint in PostgreSQL.
  • Composite keys where queries supply a prefix of the columns.
  • Tables whose size is unknown in advance on an engine with naive resizing.

Failure modes

  • Creating a hash index and then writing range or LIKE predicates against the column — the planner falls back to a scan.
  • A load factor allowed to climb until every lookup walks an overflow chain.
  • Pre-PostgreSQL-10 hash indexes lost on crash or missing on replicas.
  • Hash skew: a weak hash or a modulus that correlates with the key pattern piles keys into a few buckets.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.