SELECT * FROM users WHERE id = 42 and GET user:42. Both do one thing: fetch a single row. Yet one is described as and the other as .
Most explanations stop there. The interesting question is the next one.
If is faster, why doesn't SQL put hash tables at the centre of its design?
PostgreSQL ships a hash index. The default is still a B-tree, and hash indexes are rare in production. Why can't the faster algorithm take the lead role? Answering that means going past the complexity table and asking what each structure stores, and what it throws away.
The short answer: the difference is not speed. It is whether ordering is preserved or discarded — and ordering, once discarded, is never cheap to get back.
1. Two models of search: the phone book and the coin locker
A B-Tree is a phone book. To find "Tanaka" you do not start at page one. You open somewhere near the middle, land in the N section, and throw away the second half. Open the middle again. A thousand-page book yields to about ten such steps. This works on exactly one condition: the book is already in alphabetical order.
A hash table is a coin locker. You hold a tag that reads "247". You do not search. You walk straight to locker 247. Whether there are a hundred lockers or a million, the walk is the same length. This also works on one condition: there is a rule that computes the number from the key — a hash function.
These are not two implementations of the same act. The act of searching is itself different.
- A B-Tree compares (evaluates
key < node.keyto pick a branch). - A hash table computes (derives an address directly from
hash(key) % m).
Comparison presupposes ordering, and preserves it. Computation presupposes nothing, and destroys it. Everything that follows falls out of that single distinction.
2. Why a B-Tree's height is proportional to
"It's a tree, so it's " is a restatement, not an explanation. Here is why the height is logarithmic, proved from a lower bound.
The minimum-degree constraint
A B-Tree puts a floor on how many keys a node may hold. That floor is the mechanism that keeps it balanced. For minimum degree :
- every node except the root holds at least keys and child pointers;
- every leaf sits at the same depth (perfectly balanced).
The word "at least" is what matters. Because the structure forbids sparse nodes, the tree cannot stretch vertically into a degenerate list.
A lower bound on the keys a tree of height holds
Count nodes by depth. The root is one node, it has at least two children, and every node below that has at least children:
depth 0 (root) : 1 node
depth 1 : >= 2 nodes
depth 2 : >= 2t nodes
depth i (i>=1) : >= 2t^(i-1) nodes
The root holds at least one key and every other node at least , so the number of keys in a tree of height is bounded below by:
Solving for gives an upper bound on the height:
The height is bounded by , and a search walks a single path from root to leaf, so the cost is .
What that formula means in production
The interesting part is the base of the logarithm. It is , and that is why a B-Tree is not merely "a balanced tree".
PostgreSQL maps one node onto one disk page (8KB by default). An 8-byte bigint key leaves room for several hundred keys per page, putting somewhere around 250. Substituting :
A billion-row table is at most four pages deep. Searching the same billion rows in a binary search tree gives levels — up to 30 random I/Os. The comparison count is logarithmic either way, but moving the base from 2 to 250 changes the number of real I/Os by an order of magnitude.
That is why the B-Tree is called an algorithm for disks. What it optimises is not comparisons; it is I/O.
target = 42
Read one 8KB page, then binary-search within the node. 35 ≤ 42 < 61, so descend the third child pointer. That single comparison eliminates three quarters of the candidates.
candidates 1,000,000,000 → about 4,000,000
Four disk I/Os against a billion rows. And because leaves are linked in key order, continuing with "the next 100 rows after 42" costs no further descent — you simply read sideways. That property is the subject of the next section.
3. Reading the implementation: search and split
Here is the structure as working code. The key idea is that a node carries two arrays: keys holds the separator values and children holds the subtrees they separate, with the invariant children.length === keys.length + 1.
/**
* A teaching B-Tree node. Real engines add pages, WAL and concurrency control
* (Lehman-Yao B-link trees), but the skeleton of the search is this recursion.
*/
class BTreeNode<K> {
/** Separator keys in ascending order — this is what "preserves ordering" means. */
readonly keys: K[] = [];
/** keys.length + 1 children. Empty in a leaf. */
readonly children: BTreeNode<K>[] = [];
constructor(readonly isLeaf: boolean) {}
}
class BTree<K> {
private root = new BTreeNode<K>(true);
/** Minimum degree t: every non-root node holds between t-1 and 2t-1 keys. */
constructor(
private readonly t: number,
private readonly compare: (a: K, b: K) => number,
) {
if (!Number.isInteger(t) || t < 2) {
throw new RangeError(`minimum degree must be an integer >= 2, received ${t}`);
}
}
search(key: K): boolean {
return this.searchRecursive(this.root, key);
}
/**
* Recurses once per level. Each descent shrinks the search space by a factor
* of t, so the depth is bounded by h <= log_t((N+1)/2).
*/
private searchRecursive(node: BTreeNode<K>, key: K): boolean {
// Find the first position whose key is >= the search key (binary search in practice).
let i = 0;
while (i < node.keys.length && this.compare(key, node.keys[i]) > 0) i += 1;
if (i < node.keys.length && this.compare(key, node.keys[i]) === 0) return true;
if (node.isLeaf) return false; // absent if a leaf does not hold it
// Descend into the interval satisfying keys[i-1] < key < keys[i].
return this.searchRecursive(node.children[i], key);
}
}
Splitting is what keeps the tree balanced
The hard case on insert is a full node ( keys). A B-Tree does not grow downwards there; it pushes the median key up into the parent.
inserting 6 into a full node (t=3, five keys)
[ 1 2 3 4 5 ]
median 3 moves up to the parent, the rest splits in two
[ 3 ]
/ \
[ 1 2 ] [ 4 5 6 ]
The tree gets taller only when the root splits. That is why every leaf stays at the same depth and the worst case equals the best case. There is no "unlucky data is slow" mode — and in production it is that predictability that earns its keep.
PostgreSQL's B-Tree carries a practical consequence of this structure. The documentation notes that a single index entry cannot exceed approximately one third of a page. Indexing very long text fails for exactly that reason: keeping the tree balanced requires a minimum number of keys per page.
4. Why a hash table is
A hash table gives up comparison. In its place it needs a function that maps a key to an integer.
/**
* FNV-1a (32-bit). Ten lines, deterministic, and adjacent keys scatter to
* unrelated values. Math.imul does the multiply with 32-bit wraparound
* (a plain * loses the low bits).
*/
function fnv1a32(key: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i += 1) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
const bucketOf = (key: string, m: number): number => fnv1a32(key) % m;
The cost of bucketOf("user:42", 1024) depends only on the length of the key. It does not depend on at all — and that is what actually is.
The assumption behind : SUHA
is not unconditional. The textbook analysis assumes Simple Uniform Hashing (SUHA): any key is placed into one of the buckets independently, with equal probability .
Under that assumption, storing keys in buckets gives a load factor
and the expected cost of a lookup with separate chaining is : constant time to hash, plus comparisons walking the chain on average.
In other words, the lookup is only for as long as is bounded by a constant. Let grow without limit and you are linearly scanning a linked list that happens to live inside an array.
Keeping constant: resizing and amortised analysis
keeps growing. The only way to hold down is to grow too. So when crosses a threshold — typically 0.75 or 1.0 — the table doubles and every key is rehashed.
That resize is an operation. The claim of survives it because of amortised analysis. If the table doubles each time, the total rehashing cost across insertions is bounded by a geometric series:
Divide a total of across insertions and each insertion costs . Individual insertions are occasionally slow; the average is constant. That is the precise meaning of here.
Real implementations go further. Redis's dict does not move everything at once. Reading the source, dictRehash(dict *d, int n) performs N steps of rehashing at a time and caps how many empty buckets it will visit at n*10. This is incremental rehashing: it avoids the latency spike of a bulk copy by riding along with ordinary operations. That is the engineering that makes "amortised " hold at the tail as well as the mean.
5. Collisions are not an edge case: the pigeonhole principle
"A good hash function doesn't collide" is false. Collisions are not even a matter of probability — they are logically unavoidable.
The pigeonhole principle: place pigeons into holes and if , some hole holds at least two. A hash table maps an unbounded key space onto a finite set of buckets, so by definition collisions exist.
In practice they arrive much sooner. By the birthday approximation, a hash with possible outputs reaches a 50% collision probability at roughly keys. For a 32-bit hash ():
At just 77,000 entries it is a coin flip. So implementations do not try to avoid collisions; they make them cheap to resolve. The most basic method is separate chaining: entries landing in the same bucket are linked together.
- bucket 0
order:780xbd161ac8 % 4 = 0session:abd0x257403ec % 4 = 0collision(2) - bucket 1
user:30x6f1a4df1 % 4 = 1user:10000x507c1d89 % 4 = 1order:770xbc161935 % 4 = 1session:abc0x24740259 % 4 = 1collision(4) - bucket 2
user:20x6e1a4c5e % 4 = 2user:10010x4f7c1bf6 % 4 = 2cart:90xdf4226e2 % 4 = 2collision(3) - bucket 3
user:10x6d1a4acb % 4 = 3
Switching m moves the load factor α = n/m and the longest chain together. The longest chain is the worst-case comparison count, and a resize policy that pins α is what keeps that number constant. Look at the hex digests too: user:1000 and user:1001 land in unrelated buckets — which is exactly the loss of ordering the next section is about.
The two paths to degradation
breaks down in one of two ways.
- The hash function is bad. Skewed output concentrates keys in a few buckets, and such a bucket is just a linked list. In the worst case — every key in one bucket — a lookup is . This is why implementations choose functions such as MurmurHash or xxHash with strong avalanche behaviour: flipping one input bit flips about half the output bits.
- The table never resizes. As grows unchecked, the term in comes to dominate.
When the input is attacker-controlled there is a third path. An unkeyed hash such as FNV-1a is easy to invert, which makes hash-flooding denial of service possible: an attacker floods the table with keys that all land in one bucket. This is why Redis's dict uses a seeded SipHash as its default hash function (dictGenHashFunction passes dict_hash_function_seed into siphash()), and why the standard hash maps in Python and Rust are keyed as well. Because the seed differs per process, an attacker cannot precompute a colliding key set.
"Hash tables are " is a claim that holds only on top of two pieces of engineering: a good hash function, and a controlled load factor.
6. The heart of it: why SQL doesn't lead with hashing
Now the pieces are in place. A hash table beats a B-Tree at single-key retrieval, and the RDBMS default is still a B-tree. The reason is not speed. It is generality.
The moment a key passes through a hash function, its ordering is destroyed.
original keys: user:1000 user:1001 user:1002
| | |
FNV-1a: 0x4f3a1b2c 0xd81e07f5 0xa2b93c40
% 16: 12 5 0
Adjacent keys land in non-adjacent buckets. That is not a defect — it is the definition of a good hash function. Avalanche behaviour is precisely the property of not letting closeness in the input show up in the output.
The consequence is that every order-dependent query loses the index.
| B-Tree (preserves order) | Hash table (discards order) | |
|---|---|---|
| Single-key equality WHERE id = 42 | O(log N)(fast enough in practice)three or four levels to descend | O(1)(where this structure excels)address computed directly |
| Range scan WHERE age BETWEEN 20 AND 30 | O(log N + k)(where this structure excels)find the lower bound once, then walk the leaves | O(N)(no index support)the range is scattered; nothing but a full scan |
| Prefix match WHERE name LIKE 'tanaka%' | O(log N + k)(where this structure excels)a shared prefix is contiguous in collation order | O(N)(no index support)a shared prefix means nothing to the digest |
| Sorting ORDER BY created_at | O(k)(where this structure excels)the index is already sorted; no sort step at all | O(N log N)(no index support)fetch everything, then sort it |
| Extremes MIN(id) / MAX(id) | O(log N)(where this structure excels)read the leftmost or rightmost leaf | O(N)(no index support)no idea where it is; scan everything |
| Leftmost prefix of a composite key (tenant_id, created_at) | O(log N + k)(where this structure excels)rows sharing the leading column are physically adjacent | —(no index support)PostgreSQL hash indexes are single-column only |
Read only the first row and hashing wins. The other five all collapse to . How many of the queries your application issues are single-key fetches and nothing more? Paginated lists, date-ranged reports, prefix search on a name, newest-first ordering — most real queries want ordering.
The B-Tree concedes one step on equality, at , and in exchange serves all six rows from one index. That is what qualifies it for the lead role.
What the PostgreSQL documentation actually says
This is not folklore; the implementers say it plainly. On hash indexes, the PostgreSQL documentation states:
- they support only the
=operator, so WHERE clauses specifying range operations cannot take advantage of them; - they support single-column indexes only and do not allow uniqueness checking;
- each index tuple stores only the 4-byte hash value, not the column value — which can make them far smaller than a B-tree for long items such as UUIDs and URLs, but also makes all hash index scans lossy;
- on uneven distributions, overflow pages chain onto a bucket and a scan must walk all of them, so an unbalanced hash index "might actually be worse than a B-tree in terms of number of block accesses required, for some data".
B-trees, by contrast, handle the comparison operators <, <=, =, >=, >; constructs equivalent to combinations of them such as BETWEEN and IN are implemented as B-tree searches; and LIKE can use the index when the pattern is a constant anchored to the beginning of the string (col LIKE 'foo%' qualifies, col LIKE '%bar' does not).
" yet not the default" is not a contradiction. Giving up ordering is how the was obtained, and that same sacrifice is the narrow scope.
7. Real engines are subtler: the hybrid answer
Leaving this as "RDBMS means B-Tree, KVS means hashing" would misread modern databases. Real engines layer the two.
DynamoDB splits the key in two. The partition key is hashed internally to distribute items across physical partitions — which is why partition keys must be specified by equality and cannot be ranged over. The sort key within a partition, meanwhile, is stored in order, supporting conditions like begins_with and between. It hashes to scale horizontally and preserves order internally; ordering was never discarded.
Redis is an in-memory hash table (dict), but where ordering is required it offers a separate structure: the Sorted Set (ZSET), implemented as a skip list paired with a hash table, giving logarithmic range access by score. Again, the judgement is that hashing alone is not enough.
RocksDB and Cassandra and most persistent key-value stores are LSM-Trees. Writes append to a sorted in-memory structure and are later merged into on-disk SSTables — and an SSTable is a Sorted String Table, so ordering survives there too. On read, a Bloom filter cheaply rules out files that cannot contain the key, avoiding pointless I/O.
PostgreSQL itself uses hashing internally during execution: the Hash Join and HashAggregate nodes you see in EXPLAIN. Persistent indexes use the order-preserving B-Tree; transient intermediate work uses fast hashing — the right tool per job.
The mature answer is not "one or the other" but a hierarchy: comparison where ordering is required, computation for distribution and equality.
8. Choosing: which shape is your workload?
Turning the theory into a technology decision. The axis is not speed; it is the shape of the queries you will issue.
A hash-centric store (KVS) fits when all of these hold
- access has converged on single-key retrieval (sessions, caches, feature flags, idempotency keys);
- keys can be determined up front (the application can construct them);
- listing, aggregation and search belong to another system (an RDBMS, a search engine, a warehouse).
A B-Tree-centric store (RDBMS) fits when any of these hold
- access patterns are not settled yet, or will keep growing;
- ranges, sorting, prefix matching or aggregation appear anywhere in the requirements;
- you want the database itself to enforce relational integrity (foreign keys, unique constraints, transactions).
The most common failure in practice is choosing a KVS purely because it is "fast", then hitting a wall when the list view and the admin screen arrive. On single-key retrieval, the gap between and usually disappears into the network round trip. The gap between and on the first order-dependent query only widens as the data grows.
So the asymmetry of the decision is this:
When in doubt, choose the side that preserves ordering. You can discard ordering later; you cannot cheaply recover ordering you have already discarded.
Using a KVS as a cache layer does not overturn that. It is the correct way to use both properties: keep an order-preserving relational store as the foundation, and move only the hot single-key reads into the key-value store.
9. Summary
- A B-Tree's follows from the minimum-degree constraint, which yields . Because the base of the logarithm is the number of keys that fit in a page ( around 250 for 8KB pages), a billion rows are four I/Os away.
- A hash table's follows from computing an address instead of comparing keys. But it holds only under SUHA and a resize policy that keeps the load factor constant (amortised ) — it is not an unconditional guarantee.
- Collisions are inevitable by the pigeonhole principle, and a 32-bit hash hits 50% at roughly 77,000 keys. So designs make them cheap rather than rare: separate chaining, strong avalanche behaviour, and keyed hashing (SipHash) where the input is hostile.
- SQL's reason for not leading with hashing is ordering, not speed. Because hashing destroys ordering, range scans, prefix matching, sorting, extremes and leftmost-prefix composite lookups collapse to together. PostgreSQL's own docs restrict hash indexes to
=, to single columns, and to non-unique indexes, and warn they can be worse than a B-tree on skewed data. - Real engines answer with layering. DynamoDB hashes the partition key and orders the sort key. Redis is hash-first but ships ZSET for ordering. PostgreSQL indexes with B-Trees and joins with hashing.
Complexity tables are not something to memorise. They are derivable, every time, from what the structure stores and what it gave up. Hold on to that one distinction — preserve ordering, or discard it — and the next time you are choosing a storage engine you will be able to ask, before any benchmark number, what this engine gave up in order to be that fast.