Skip to content

Database Indexing: A Deep Dive

Indexes can make or break your application's performance — here's how they work under the hood and when to use each type.

3 min read
B-tree index structure diagram showing how database lookups traverse tree nodes

Slow queries are rarely a database problem — they're an indexing problem. A missing index turns a 2ms lookup into a 2-second full table scan. A misplaced index wastes disk space and slows down writes for zero read benefit. Understanding how indexes actually work changes the way you design schemas and write queries.

How B-Tree Indexes Work

The default index type in PostgreSQL, MySQL, and most relational databases is a B-tree. It's a sorted, balanced tree structure that allows O(log n) lookups instead of O(n) sequential scans.

sqlsql
-- Without an index, this scans every row in the table
SELECT * FROM orders WHERE customer_id = 'cust_abc123';
-- Seq Scan on orders: rows=1,000,000, time=1,847ms
 
-- With an index, it traverses a tree to find matching rows
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
-- Index Scan using idx_orders_customer_id: rows=47, time=0.8ms

The index stores customer_id values in sorted order with pointers to the actual table rows. Instead of reading a million rows, the database walks a tree of maybe 3-4 levels deep.

Composite Indexes and Column Order

A composite index covers multiple columns. The column order determines which queries benefit.

sqlsql
-- ❌ Two separate indexes — only one gets used per query
CREATE INDEX idx_status ON orders (status);
CREATE INDEX idx_date ON orders (created_at);
 
-- This query can only use ONE of these indexes, then filters the rest
SELECT * FROM orders 
WHERE status = 'shipped' AND created_at > '2020-01-01';
 
-- ✅ Composite index — covers the entire WHERE clause
CREATE INDEX idx_orders_status_date ON orders (status, created_at);
 
-- Now the database uses one index seek for both conditions

The leftmost prefix rule matters: a composite index on (status, created_at) supports queries filtering on status alone or status + created_at, but not created_at alone. Think of it like a phone book — you can find all Smiths, or Smith + John, but you can't efficiently find all Johns across all last names.

sqlsql
-- ✅ Uses the composite index (leftmost prefix)
SELECT * FROM orders WHERE status = 'pending';
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2020-01-01';
 
-- ❌ Cannot use the composite index efficiently
SELECT * FROM orders WHERE created_at > '2020-01-01';

Covering Indexes

A covering index includes all columns a query needs, eliminating the need to read the actual table row. This is an index-only scan — the fastest possible read path.

sqlsql
-- Query needs customer_id and total
SELECT customer_id, total FROM orders WHERE status = 'completed';
 
-- Regular index — finds rows via index, then fetches from table (heap)
CREATE INDEX idx_status ON orders (status);
 
-- Covering index — all needed columns are IN the index
CREATE INDEX idx_status_covering ON orders (status) INCLUDE (customer_id, total);
-- Index Only Scan: no heap fetches needed

The tradeoff: covering indexes are larger and slower to update. Use them for read-heavy queries on columns that rarely change.

Partial Indexes

Why index rows you never query? Partial indexes cover a subset of the table, saving space and write overhead.

sqlsql
-- ❌ Full index — includes millions of completed orders you rarely query
CREATE INDEX idx_orders_status ON orders (status);
 
-- ✅ Partial index — only indexes the rows you actually filter on
CREATE INDEX idx_orders_active ON orders (status)
WHERE status IN ('pending', 'processing', 'shipped');
tstypescript
// Common use case: "active" records in a soft-delete system
// Only 5% of users are active, but 95% of queries filter on active
 
// The partial index is 20x smaller and 20x faster to maintain

When Indexes Hurt

Indexes aren't free. Every INSERT, UPDATE, and DELETE must update all relevant indexes. Over-indexing slows writes and wastes storage.

sqlsql
-- ❌ Over-indexed table — every write updates 8 indexes
-- orders table with indexes on:
-- (id), (customer_id), (status), (created_at), (updated_at),
-- (total), (shipping_method), (payment_status)
 
-- Each INSERT now writes to 9 locations (1 table + 8 indexes)
ScenarioIndex recommendation
Read-heavy, few writes (analytics)Index generously
Write-heavy, few reads (event log)Minimal indexes
Mixed workload (typical app)Index only queried columns
Wide table, narrow queriesUse covering indexes selectively

EXPLAIN Is Your Best Friend

Before adding an index, use EXPLAIN ANALYZE to understand the current query plan. After adding it, verify the index is actually used.

sqlsql
EXPLAIN ANALYZE
SELECT * FROM orders 
WHERE customer_id = 'cust_abc123' 
  AND status = 'pending'
ORDER BY created_at DESC
LIMIT 10;
 
-- Look for:
-- "Seq Scan" → needs an index
-- "Index Scan" → using an index
-- "Index Only Scan" → best case, covering index
-- "Bitmap Index Scan" → multiple indexes combined
-- "actual time" → real execution time, not estimates

Don't trust the query planner's estimates — always use ANALYZE to see actual execution times. And run ANALYZE on the table after adding data so the planner has current statistics.

Key Takeaways

  1. B-tree indexes turn O(n) scans into O(log n) lookups — one missing index can make a query 1000x slower
  2. Column order in composite indexes matters — the leftmost prefix rule determines which queries benefit
  3. Covering indexes eliminate table lookups — include frequently selected columns with INCLUDE
  4. Partial indexes save space — only index the rows your queries actually filter
  5. Over-indexing slows writes — every index is maintained on every mutation
  6. Always validate with EXPLAIN ANALYZE — don't guess, measure
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX