Loading learning content...
If B-trees offer O(log N) lookups, and hash tables offer O(1) lookups, why would anyone use B-trees? This seemingly obvious question leads to a nuanced understanding of database indexing that separates superficial knowledge from deep expertise.
Hash indexes leverage hash tables—the same data structure you use for dictionaries and maps in application code—to provide constant-time exact-match lookups. In theory, this makes them the fastest possible index type for equality queries. In practice, their adoption in database systems is surprisingly limited.
This page explores hash indexes comprehensively: their internal mechanics, their compelling strengths, their critical limitations, and the specific scenarios where they represent the optimal indexing choice.
By the end of this page, you'll understand how hash indexes work internally, why O(1) isn't always better than O(log N), when hash indexes are the right choice, which databases support them, and how to make informed decisions between hash and B-tree indexes.
A hash index is built on a hash table data structure. Understanding how hash tables work is essential for understanding hash index behavior.
The Hash Function:
At the core of any hash index is a hash function that maps keys to bucket locations:
hash(key) → bucket_number
Good hash functions have these properties:
Databases typically use cryptographic-strength or near-cryptographic hash functions (like MurmurHash, CityHash, or xxHash) to ensure good distribution.
Bucket Structure:
The hash table consists of an array of buckets (also called slots). Each bucket can store one or more entries. The number of buckets is typically a power of 2 to enable fast modulo operations:
bucket_number = hash(key) mod num_buckets
Hash Index Structure Hash function: hash(key) mod 8 (8 buckets) Keys to index: 'alice@email.com', 'bob@email.com', 'carol@email.com' hash('alice@email.com') = 0xA3F2... mod 8 = 5hash('bob@email.com') = 0x7B21... mod 8 = 2 hash('carol@email.com') = 0x2E45... mod 8 = 5 ← Collision with alice! Bucket Array:┌────────────────────────────────────────────────────────┐│ Bucket 0 │ (empty) │├──────────┼─────────────────────────────────────────────┤│ Bucket 1 │ (empty) │├──────────┼─────────────────────────────────────────────┤│ Bucket 2 │ 'bob@email.com' → row_ptr_42 │├──────────┼─────────────────────────────────────────────┤│ Bucket 3 │ (empty) │├──────────┼─────────────────────────────────────────────┤│ Bucket 4 │ (empty) │├──────────┼─────────────────────────────────────────────┤│ Bucket 5 │ 'alice@email.com' → row_ptr_17 ││ │ 'carol@email.com' → row_ptr_89 ← Chained │├──────────┼─────────────────────────────────────────────┤│ Bucket 6 │ (empty) │├──────────┼─────────────────────────────────────────────┤│ Bucket 7 │ (empty) │└──────────┴─────────────────────────────────────────────┘ Lookup 'bob@email.com':1. Compute hash('bob@email.com') mod 8 = 22. Go directly to bucket 23. Find 'bob@email.com' → return row_ptr_424. Total operations: 1 hash + 1 bucket access = O(1)Collision Handling:
When two different keys hash to the same bucket, we have a collision. Databases handle collisions through chaining or open addressing:
Chaining (most common in databases):
Open Addressing (less common):
Load Factor:
The load factor (entries / buckets) determines collision probability:
Databases typically resize the hash table to maintain a load factor between 0.5 and 0.75.
O(1) lookup assumes the hash computation is O(1) relative to n, collisions are bounded (average case), and the hash table doesn't need resizing. In the worst case (all keys in one bucket), hash table lookup degrades to O(n). Good hash functions make this practically impossible for reasonable data.
Understanding the exact lookup process reveals why hash indexes are so fast for equality queries.
Step-by-Step Lookup:
12345678910111213141516171819
-- Query using hash indexSELECT * FROM users WHERE email = 'alice@example.com'; -- If there's a HASH index on email: -- PostgreSQL execution:-- 1. Calculate hash('alice@example.com')-- 2. Locate bucket in hash index-- 3. Read bucket page (1 I/O or cache hit)-- 4. Find matching entry in bucket-- 5. Use row pointer to read table row (1 I/O or cache hit)-- Total: 2 I/O operations (both often cached → <1ms) -- Compare to B-tree:-- 1. Read root page-- 2. Read internal page(s)-- 3. Read leaf page-- 4. Use row pointer to read table row-- Total: 3-4 I/O operations (still very fast)Why O(1) vs O(log N) Doesn't Matter as Much as You'd Think:
Let's compare hash vs B-tree for a 1-billion row table:
| Index Type | Index Traversal | I/O Operations |
|---|---|---|
| Hash index | 1 bucket access | 1 |
| B-tree index | log₅₀₀(1B) ≈ 3-4 levels | 3-4 |
The hash index saves 2-3 I/O operations per query. At 100μs per SSD read:
This is a 3-4x improvement—significant, but not the orders-of-magnitude difference that "O(1) vs O(log N)" might suggest. The logarithm grows so slowly that for practical database sizes, it's effectively a small constant.
When Cache Matters:
With good caching (buffer pool holds hot pages):
Hash indexes also benefit from caching, but B-tree's upper levels are reused across all queries, while hash buckets are more dispersed.
In real-world systems, the difference between hash and B-tree index lookups is often measured in tens of microseconds. Both are 'fast enough' for most applications. The choice should be driven by query patterns (equality vs range), not theoretical complexity classes.
The fundamental limitation of hash indexes is their inability to support range queries, ordering, or prefix matching. This single limitation explains why B-trees dominate despite their theoretical inferiority for point lookups.
Why Hash Indexes Can't Support Ranges:
Hash functions are designed to produce pseudo-random output. Two similar keys will hash to completely different buckets:
hash('alice') → bucket 42
hash('aliceb') → bucket 7
hash('alicea') → bucket 128
There's no relationship between key proximity and bucket location. To find all keys between 'alice' and 'bob', you'd need to scan every bucket—which defeats the purpose of indexing entirely.
| Query Pattern | Hash Index | B-tree Index | Example |
|---|---|---|---|
| Exact equality | ✅ O(1) | ✅ O(log N) | WHERE id = 42 |
| IN list | ✅ O(k) | ✅ O(k log N) | WHERE id IN (1,2,3) |
| Range (BETWEEN) | ❌ Full scan | ✅ O(log N + k) | WHERE price BETWEEN 10 AND 50 |
| Greater/Less than | ❌ Full scan | ✅ O(log N + k) | WHERE created_at > '2024-01-01' |
| ORDER BY | ❌ Extra sort | ✅ Index order | ORDER BY name ASC |
| MIN/MAX | ❌ Full scan | ✅ O(log N) | SELECT MAX(price) |
| Prefix LIKE | ❌ Full scan | ✅ Range scan | WHERE name LIKE 'Al%' |
| Suffix LIKE | ❌ Full scan | ❌ Full scan | WHERE name LIKE '%son' |
| GROUP BY | ❌ Hash+sort | ✅ Often avoids sort | GROUP BY category |
The Practical Impact:
Most real-world queries involve more than pure equality:
-- Dashboard query: needs ranges and ordering
SELECT * FROM orders
WHERE created_at > CURRENT_DATE - INTERVAL '30 days'
ORDER BY created_at DESC
LIMIT 100;
-- Analytics query: needs aggregation with ordering
SELECT status, COUNT(*)
FROM orders
GROUP BY status
ORDER BY COUNT(*) DESC;
-- Search query: needs prefix matching
SELECT * FROM products
WHERE name LIKE 'Apple%';
None of these queries can use a hash index effectively. A single B-tree index on created_at or name handles all of them.
The Versatility Tax:
B-trees pay a small performance tax on equality queries (3x slower than hash) but support every query pattern. Hash indexes are faster for one pattern but useless for everything else. For most systems, B-tree versatility wins.
If you create a hash index and later need range queries, you must create an additional B-tree index—doubling maintenance overhead and storage. Starting with B-tree avoids this trap. Choose hash only when you're certain you'll never need range support on that column.
Hash index maintenance involves different trade-offs compared to B-trees. Understanding these helps evaluate the total cost of hash index ownership.
Insert Operations:
Hash index inserts are typically O(1):
However, if the table needs resizing (load factor too high), the cost spikes dramatically:
Resizing (Rehashing):
When the hash table grows past its load factor threshold, it must:
This resize operation is expensive and causes latency spikes. Some hash implementations use incremental resizing (rehash a few entries per operation) to amortize this cost.
Space Utilization:
Hash indexes can have significant space overhead:
The total space is often similar to or greater than B-trees, despite storing the same information.
Fragmentation:
Hash indexes can fragment when:
Unlike B-trees (where REINDEX is well-understood), hash index maintenance is less standardized across databases.
PostgreSQL's hash indexes were historically not WAL-logged and unsafe for crash recovery. As of PostgreSQL 10, they're fully WAL-logged but still have limitations (no concurrent bulk loading, for example). Always check your database's specific hash index implementation and guarantees.
To address the resize problem, database systems use sophisticated hashing schemes that grow incrementally.
Extendible Hashing:
Extendible hashing uses a directory that doubles in size independently of the bucket pages:
This approach:
Extendible Hashing Example Initial state (global depth = 1, using 1 bit of hash):┌─────────────┐│ Directory │├─────────────┤│ 0 → Bucket A│ ┌─────────────────┐│ │───→│ Bucket A (d=1) ││ 1 → Bucket B│ │ [entries...] ││ │───→└─────────────────┘└─────────────┘ ┌─────────────────┐ │ Bucket B (d=1) │ ───→│ [entries...] │ └─────────────────┘ After Bucket A overflows (split):- Bucket A splits into A' and A''- Directory depth increases to 2 bits- Only the overflowing bucket was rehashed! ┌─────────────┐│ Directory │├─────────────┤ ┌─────────────────┐│ 00 → Bucket A'│──→│ Bucket A' (d=2) ││ 01 → Bucket A''│ │ [entries w/ 00] ││ 10 → Bucket B│ └─────────────────┘│ 11 → Bucket B│ ┌─────────────────┐└─────────────┘ ──→│ Bucket A'' (d=2)│ │ [entries w/ 01] │ Note: 10 and └─────────────────┘ 11 still point ┌─────────────────┐ to same bucket!│ Bucket B (d=1) │ ───→│ [entries...] │← Not split, not rehashed └─────────────────┘Linear Hashing:
Linear hashing provides even smoother growth:
Advantages:
Trade-offs:
Database Usage:
These techniques are used in:
Even if you don't use hash indexes explicitly, databases use hash tables internally for: hash joins (building hash table from smaller table), hash aggregation (GROUP BY), detecting duplicates (DISTINCT), and building temporary result sets. Understanding hash table behavior helps you tune these operations.
Hash index support varies significantly across database systems, reflecting different design philosophies and use cases.
| Database | Hash Index Support | Notes |
|---|---|---|
| PostgreSQL | ✅ Yes (since v10) | Fully WAL-logged, but limited. Use for pure equality on unique columns. |
| MySQL (InnoDB) | ❌ No | InnoDB uses only B+ tree indexes. MEMORY engine supports hash. |
| MySQL (MEMORY) | ✅ Yes | MEMORY engine default. Fast but data is not persistent. |
| SQL Server | ❌ No disk-based | Only for memory-optimized tables (Hekaton). |
| Oracle | ❌ No traditional | Index-organized tables use B-tree. Hash partitioning available. |
| SQLite | ❌ No | B-tree only for indexes. |
| MongoDB | ✅ Yes (hashed) | Hashed indexes for shard keys. Cannot support range queries. |
| Redis | ✅ Inherent | Core data structure is hash table. O(1) key lookups. |
| DynamoDB | ✅ Inherent | Partition key lookup is hash-based. O(1) for key access. |
PostgreSQL Hash Indexes:
PostgreSQL provides explicit hash index support:
-- Create hash index
CREATE INDEX idx_users_email_hash
ON users USING HASH (email);
-- Only useful for equality
SELECT * FROM users WHERE email = 'alice@example.com'; -- Uses hash
SELECT * FROM users WHERE email LIKE 'alice%'; -- Can't use hash
When to Use (PostgreSQL):
MySQL/InnoDB Workaround:
Since InnoDB doesn't support hash indexes, you can simulate them:
-- Add a hash column
ALTER TABLE users ADD COLUMN email_hash BIGINT UNSIGNED;
UPDATE users SET email_hash = CRC32(email);
CREATE INDEX idx_email_hash ON users(email_hash);
-- Query using hash (still need email check for collision handling)
SELECT * FROM users
WHERE email_hash = CRC32('alice@example.com')
AND email = 'alice@example.com';
This gives hash-like performance with B-tree storage.
Key-value stores (Redis, DynamoDB) are fundamentally hash-based. This is why they have O(1) key lookups but struggle with range queries. DynamoDB requires sort keys for ranges; Redis requires Sorted Sets. The hash vs B-tree trade-off applies across all data systems.
Given the trade-offs, when should you choose a hash index over a B-tree? The decision should be based on clear, measurable criteria.
Real-World Hash Index Use Cases:
1. Session Lookup Tables:
-- Session ID lookup is always exact match
CREATE TABLE sessions (
session_id VARCHAR(64) PRIMARY KEY,
user_id INTEGER,
created_at TIMESTAMP
);
CREATE INDEX idx_session_hash ON sessions USING HASH (session_id);
-- Query pattern: always equality
SELECT * FROM sessions WHERE session_id = 'abc123...';
2. Cache Key Tables:
-- Cache keys are arbitrary strings, looked up by exact match
CREATE TABLE cache (
cache_key VARCHAR(255) PRIMARY KEY,
value JSONB,
expires_at TIMESTAMP
);
CREATE INDEX idx_cache_key_hash ON cache USING HASH (cache_key);
3. UUID/GUID Lookups:
-- UUIDs are random, equality-only, high cardinality
CREATE TABLE documents (
doc_id UUID PRIMARY KEY,
content JSONB
);
CREATE INDEX idx_doc_hash ON documents USING HASH (doc_id);
When in doubt, use B-tree. It handles all query patterns well. Only switch to hash when you have clear evidence that: (1) queries are exclusively equality, (2) B-tree is a measured bottleneck, and (3) hash indexes provide verified improvement on your workload.
In distributed databases, hashing plays a crucial role beyond indexing—it determines data distribution across nodes.
Hash Partitioning (Sharding):
Distributed databases use hash functions to determine which node stores each record:
node = hash(shard_key) mod num_nodes
This ensures even data distribution across the cluster. Examples:
Consistent Hashing:
Basic modulo hashing has a problem: when you add a node, every record potentially moves. Consistent hashing solves this:
Consistent Hashing Ring Hash ring (0 to 360 degrees for simplicity): 0°/360° ● / \ N1 / \ N4 ● 45° ● 315° / \ / \ ● 90° ● 270° N2 (empty) \ / \ / ● 135° ● 225° N3 (empty) ● 180° Keys are hashed and stored on the next clockwise node:- hash('user_1') = 30° → Stored on N1 (45°)- hash('user_2') = 100° → Stored on N3 (135°)- hash('user_3') = 350° → Stored on N1 (45°, wraps around) Adding a new node N5 at 60°:- Only keys between 45° and 60° move from N2 to N5- All other data stays in place! Virtual nodes: Each physical node has multiple points on ringfor better distribution.Hash Indexes vs Hash Partitioning:
These are related but distinct concepts:
| Concept | Purpose | Scope |
|---|---|---|
| Hash Index | Fast equality lookup within a table | Single node |
| Hash Partition | Distribute data across cluster nodes | Cluster-wide |
| Consistent Hashing | Minimize data movement on cluster changes | Cluster topology |
Query Routing with Hash Partitioning:
When data is hash-partitioned:
This is why hash-partitioned systems (DynamoDB, Cassandra) have excellent single-key performance but struggle with range queries—the limitation mirrors local hash indexes.
When range queries are essential, databases use range partitioning instead of hash partitioning. Data is split by key ranges, enabling efficient range scans within a single partition. The trade-off is potential hotspots if keys are accessed unevenly. Many systems (CockroachDB, TiDB) use range partitioning with automatic splitting.
We've explored hash indexes comprehensively, from their theoretical advantages through their practical limitations. Let's consolidate the essential insights:
What's Next:
With single-column indexes covered (B-tree and hash), we move to a more complex topic in the next page: Composite indexes. You'll learn how to design indexes on multiple columns, understand column order importance, and master the prefix rule that governs composite index usage.
You now understand when hash indexes shine, when they fail, and how to make informed decisions between hash and B-tree indexes. This understanding extends to distributed systems where hash-based partitioning follows similar principles. Use B-tree as your default; switch to hash only with solid justification.