How Database Indexes Actually Work — A Visual Deep Dive
Every developer has written CREATE INDEX at some point. Most know it “makes queries faster.” But few actually understand why — or when an index makes things worse.
This post tears the black box open. We’ll walk through the data structures, the math, the tradeoffs, and the real-world decisions that separate someone who guesses at indexing from someone who knows.
Note
This post uses PostgreSQL as the reference database, but the concepts apply to MySQL, SQLite, and most relational databases. The underlying data structures are the same.
The Problem: Why We Need Indexes
Imagine a users table with 10 million rows. You run:
SELECT * FROM users WHERE email = 'kushagra@example.com';Without an index, PostgreSQL does a sequential scan — it reads every single row, checks the email column, and returns matches. That’s 10 million comparisons for a single lookup.
The time complexity? — linear. Double your data, double your query time.
With a B-Tree index on email, that same query does comparisons. For 10 million rows, that’s roughly:
Twenty-three. Not ten million. Twenty-three.
Tip
You can see whether PostgreSQL is using a sequential scan or an index scan by prepending EXPLAIN ANALYZE to your query. If you see Seq Scan, you’re reading the entire table.
B-Tree: The Workhorse
95% of the indexes you’ll ever create are B-Tree indexes1. They’re the default in PostgreSQL, MySQL, and SQLite.
A B-Tree is a self-balancing tree where:
- Every node can hold multiple keys (not just one like a binary tree)
- All leaf nodes are at the same depth
- Data is sorted, so range queries are efficient
Structure
Loading diagram...When you search for email = 'kushagra@example.com':
- Start at root — is “kushagra” before or after the keys?
- Follow the correct pointer down one level
- Repeat until you hit a leaf node
- The leaf contains a pointer to the actual row on disk
Each level eliminates a large fraction of the remaining data. With a branching factor of , the height of the tree is:
For (typical for PostgreSQL) and :
Four disk reads. That’s it.
Important
B-Trees aren’t binary trees. The “B” doesn’t stand for “binary” — it likely stands for “balanced” or is named after its creators at Boeing. Each node holds many keys (often 100+), which maps well to disk page sizes (typically 8KB in PostgreSQL).
Hash Indexes: The Specialist
Hash indexes use a hash function to map keys directly to row locations. The lookup is — constant time, regardless of table size.
Sounds better than B-Trees, right? So why aren’t they the default?
| Feature | B-Tree | Hash |
|---|---|---|
Equality lookup (=) |
||
Range queries (<, >, BETWEEN) |
✅ Fast | ❌ Impossible |
ORDER BY |
✅ Already sorted | ❌ No ordering |
LIKE 'prefix%' |
✅ Works | ❌ Nope |
IS NULL |
✅ Works | ❌ Not supported |
| Write overhead | Moderate | Low |
| Crash recovery | ✅ WAL-logged | ✅ Since PG 102 |
Hash indexes are only useful for exact equality on high-cardinality columns — like UUIDs or API keys. For everything else, B-Trees win.
-- Hash index: only good for exact matches
CREATE INDEX idx_api_keys_hash ON api_keys USING hash (key);
-- B-Tree: the default, handles everything
CREATE INDEX idx_users_email ON users (email);Caution
Before PostgreSQL 10, hash indexes were not crash-safe — they weren’t written to the Write-Ahead Log (WAL). If your database crashed, the index could be corrupted. Always check your PG version before using hash indexes in production.
Composite Indexes: Order Matters
A composite index covers multiple columns. But the column order is critical.
CREATE INDEX idx_posts_status_date ON posts (published, created_at);This index is useful for:
-- ✅ Uses index (leftmost prefix)
SELECT * FROM posts WHERE published = true;
-- ✅ Uses index (both columns, left to right)
SELECT * FROM posts WHERE published = true AND created_at > '2024-01-01';
-- ❌ Cannot use index (skips the first column)
SELECT * FROM posts WHERE created_at > '2024-01-01';This is called the leftmost prefix rule. Think of a composite index like a phone book sorted by last name, then first name. You can look up “Sharma” efficiently. You can look up “Sharma, Kushagra” efficiently. But you cannot look up just “Kushagra” — the book isn’t sorted that way.
When to Use Composite Indexes
Loading diagram...Tip
Put the most selective column first in a composite index. Selectivity = number of distinct values / total rows. A column with 2 values (like published) is less selective than one with 10,000 values (like user_id). However, if you always filter on published first, it should still come first — query patterns matter more than selectivity alone.
The Cost of Indexes
Indexes aren’t free. Every index you add:
- Slows down writes — every
INSERT,UPDATE, andDELETEmust also update the index - Uses disk space — an index on a text column can be 30-50% the size of the table
- Requires maintenance —
VACUUMandREINDEXbecome necessary as data changes
The write penalty per index is roughly:
Where is the number of indexes. Five indexes on a table means every insert does six writes (one for the table, five for indexes).
A Real-World Example
Here’s a schema I recently indexed for a portfolio site:
// Drizzle ORM — adding indexes to a posts table
export const posts = pgTable("posts", {
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
username: text("username").notNull(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
published: boolean("published").notNull(),
trashed: boolean("trashed").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
index("posts_published_idx").on(table.published),
index("posts_user_id_idx").on(table.userId),
index("posts_username_idx").on(table.username),
index("posts_created_at_idx").on(table.createdAt),
]);Four indexes. Every public query filters on published and trashed. User profile pages filter on username. Listing pages sort by created_at. Each index serves a specific query pattern — nothing speculative.
Warning
Don’t index every column “just in case.” Every unused index is wasted disk space and slower writes with zero benefit. Index what you query, benchmark, and remove what doesn’t help. Use pg_stat_user_indexes to find indexes with zero scans.
Partial Indexes: The Secret Weapon
Most tutorials skip this one. A partial index only indexes rows that match a condition:
CREATE INDEX idx_posts_published ON posts (created_at)
WHERE published = true AND trashed = false;This index is smaller, faster to update, and only covers the rows you actually query. If 90% of your queries are for published, non-trashed posts, this index is perfect — and it’s a fraction of the size of a full index.
Loading diagram...For a table with 1,000 posts where 850 are published, the partial index is 85% the size — or more accurately, it only contains the 850 rows that matter.
Measuring Impact
Theory is nice. Numbers are better. Here’s how to actually measure whether your index helps:
-- Before: check the query plan
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM posts
WHERE published = true
ORDER BY created_at DESC
LIMIT 20;
-- Look for:
-- Seq Scan → bad (reading whole table)
-- Index Scan → good (using your index)
-- Bitmap Index Scan → okay (using index, then heap)
-- Index Only Scan → best (data is in the index itself)Key metrics to watch:
Seq Scan→Index Scan: query is using the indexactual time: real execution time in millisecondsBuffers: shared hit: pages read from cache (lower = better)Buffers: shared read: pages read from disk (this is the slow part)
Rules of Thumb
After years of indexing production databases, here’s what I’ve learned:
- Index what you
WHERE,JOIN, andORDER BY— not what youSELECT - The leftmost prefix rule is non-negotiable — always query from left to right
- Partial indexes are underused — if you always filter on a condition, make it partial
- Covering indexes eliminate heap lookups — include all selected columns with
INCLUDE EXPLAIN ANALYZEis your best friend — never guess, always measure- Monitor
pg_stat_user_indexes— delete indexes withidx_scan = 0
Tip
In PostgreSQL 11+, you can create “covering indexes” that include non-key columns: CREATE INDEX idx ON posts (published) INCLUDE (title, created_at). This allows Index Only Scans where the database never touches the table at all — it gets everything from the index.
Conclusion
Indexes aren’t magic. They’re B-Trees — balanced, sorted, pointer-based data structures that trade write speed for read speed. Understanding how they work gives you the ability to make informed decisions instead of cargo-culting CREATE INDEX and hoping for the best.
The next time you add an index, ask yourself:
- What queries does this serve?
- Is the column selective enough?
- Would a partial index be better?
- Am I willing to pay the write cost?
If you can answer all four, you’re not guessing anymore. You know.
Want more deep dives like this? Subscribe to my newsletter – I write about systems, performance, and the things they don’t teach you in tutorials.
Footnotes
-
The B-Tree was invented by Rudolf Bayer and Edward McCreight at Boeing Research Labs in 1972. The original paper is “Organization and Maintenance of Large Ordered Indexes” – still worth reading today. ↩
-
Hash indexes became WAL-logged (and therefore crash-safe) in PostgreSQL 10, released in 2017. Before that, they were essentially unusable in production. See the PG 10 release notes for details. ↩
Loading comments...