The Problem: Full Table Scans
Without an index, a database has no choice but to read every row in a table to find the ones that match your query. This is a full table scan. On a table with a million rows, every query that lacks an index reads a million rows, even if the result is a single record.
Without an index: every query is O(n)
-- Table: users (1,000,000 rows) -- Query: find a user by email SELECT * FROM users WHERE email = 'alice@example.com'; -- What happens internally: -- Row 1: email = 'bob@...' -> skip -- Row 2: email = 'carol@...' -> skip -- ... -- Row 847,291: email = 'alice@...' -> match! -- Row 847,292 ... 1,000,000 -> still have to check -- Total rows read: 1,000,000 -- Time: ~200ms on a warm SSD
An index is a separate data structure maintained alongside your table. It stores a sorted copy of the indexed column(s) along with pointers to the actual rows. Instead of scanning the table, the database searches the index, finds the pointer, and jumps directly to the matching row. The same query drops from O(n) to O(log n).
With an index: O(log n) lookup
-- Create the index once CREATE INDEX idx_users_email ON users(email); -- Same query, now uses the index SELECT * FROM users WHERE email = 'alice@example.com'; -- What happens internally: -- B-tree root: 'alice' < 'mike', go left -- Next node: 'alice' > 'adam', go right -- Next node: 'alice' found -> row pointer: #847291 -- Heap fetch: read row #847291 directly -- Total rows read: 1 -- Comparisons: ~20 (log2 of 1M) -- Time: <1ms
The cost of indexes
Indexes are not free. Every index you add must be maintained by the database on every INSERT, UPDATE, and DELETE. For read-heavy workloads, this is a good trade. For write-heavy workloads, excessive indexing becomes the bottleneck.
O(log n) instead of O(n). 1ms instead of 200ms on a million-row table.
Every insert, update, or delete must update the index B-tree. Cost scales with number of indexes.
A B-tree index on a large table can consume gigabytes. Monitor total index size vs table size.
Rule of thumb: index columns that appear in WHERE, JOIN ON, and ORDER BY clauses. Do not index every column. A table with 20 indexes and heavy write traffic will be slower than one with 5 well-chosen indexes.
How B-tree Indexes Work
The default index type in PostgreSQL, MySQL, and most other relational databases is a B-tree (balanced tree). The B-tree is a self-balancing sorted tree that keeps all leaf nodes at the same depth. This guarantees that any lookup takes exactly O(log n) comparisons, regardless of which key you are searching for.
The visualizer below uses a 16-row table. Select a search target, then click "Run comparison" to see a full table scan and a B-tree lookup side by side. Notice that the scan checks rows one by one while the index descends the tree in exactly 4 comparisons, regardless of which value you search for.
B-tree structure, 16 rows, height 4
Full table scan
B-tree index
B-tree properties
Balanced height
All paths from root to leaf have the same length. A table with 1 billion rows has a B-tree height of roughly 30. Every lookup takes at most 30 comparisons.
Sorted order
Keys in every node are sorted. This makes range queries efficient: find the start key via binary search, then scan leaves sequentially.
High branching factor
Real B-trees (B+ trees) store hundreds of keys per node, not just two children. A tree with 1B rows might be only 3-4 levels deep.
Leaf chaining
Leaf nodes are linked in sorted order. Range scans (WHERE age BETWEEN 20 AND 30) traverse the chain without backtracking to the root.
O(log n) in practice
| Table rows | Full scan | B-tree depth |
|---|---|---|
| 1,000 | 1,000 reads | ~10 levels |
| 100,000 | 100,000 reads | ~17 levels |
| 1,000,000 | 1,000,000 reads | ~20 levels |
| 1,000,000,000 | 1,000,000,000 reads | ~30 levels |
Real B+ trees with branching factor of 100-200 are even shallower. Most production tables are reachable in 3-5 levels regardless of size.
B-trees work well for equality lookups (WHERE id = 42), range queries (WHERE created_at BETWEEN ...), and ORDER BY on indexed columns. They do not help with wildcard prefix searches (WHERE name LIKE '%smith') or full-text matching. Use a dedicated full-text index (tsvector, Elasticsearch) for those.
Index Design: Types and Trade-offs
Not all indexes are equal. The type of index and how you design it determines whether the database can use it for a given query. Understanding these types lets you build indexes that are both fast and efficient.
An index on one column. The most common form. Speeds up equality and range queries on that column.
CREATE INDEX idx_users_email ON users(email); -- Queries that use this index: WHERE email = 'alice@example.com' -- equality WHERE email > 'l@...' -- range ORDER BY email -- sort
The database may ignore the index if the column has very few distinct values (low cardinality) or if the query returns most of the table.
An index on multiple columns. The order of columns matters critically. The index can be used for queries that filter on the leftmost prefix of the index columns.
-- Index on (last_name, first_name, age) CREATE INDEX idx_name ON users(last_name, first_name, age); -- Uses the index: WHERE last_name = 'Smith' WHERE last_name = 'Smith' AND first_name = 'Alice' WHERE last_name = 'Smith' AND first_name = 'Alice' AND age > 30 -- Does NOT use the index: WHERE first_name = 'Alice' -- skips leftmost column WHERE age > 30 -- skips two leftmost columns
Put the most selective column (most distinct values) first, unless your most common query always filters on a specific column.
An index that includes all columns needed by a query. The database can answer the query entirely from the index, without touching the main table (heap). This is called an index-only scan.
-- Query: get name and email for users in a role SELECT name, email FROM users WHERE role = 'admin'; -- A covering index includes all needed columns CREATE INDEX idx_role_covering ON users(role, name, email); -- Now the query never touches the users table: -- Index has role (for filtering) + name + email (for SELECT) -- Result: index-only scan, zero heap fetches
Covering indexes use more storage but eliminate the most expensive part of a lookup: the heap fetch. Benchmark before adding them blindly.
An index that only covers rows matching a WHERE condition. Smaller than a full index, faster to maintain, and can be more selective for the queries that need it.
-- Only index active users (not the 90% who churned) CREATE INDEX idx_active_users ON users(email) WHERE status = 'active'; -- Only index unprocessed orders CREATE INDEX idx_pending_orders ON orders(created_at) WHERE processed = false; -- The query must include the same WHERE clause -- for the database to choose the partial index
Ideal when most of your queries filter on a condition that excludes large portions of the table (soft-deleted rows, inactive records, processed events).
Composite index column order
The leftmost prefix rule catches many engineers off-guard. Given an index on (A, B, C), the database can use it for queries filtering on A, on A+B, or on A+B+C. It cannot use it for queries filtering only on B or only on C.
Index (department, hire_date, salary), what gets used
WHERE department = 'Eng'WHERE department = 'Eng' AND hire_date > '2023-01-01'WHERE department = 'Eng' AND hire_date > '2023-01-01' AND salary > 80000WHERE hire_date > '2023-01-01'WHERE salary > 80000WHERE department = 'Eng' AND salary > 80000Query Optimization and Common Pitfalls
Even with indexes in place, queries can be slow if they are written in ways the database cannot optimize. The most common culprits are the N+1 problem, functions applied to indexed columns, and access patterns that invalidate the index entirely.
Reading EXPLAIN output
Every major database has an EXPLAIN command that shows how a query will be executed. Reading EXPLAIN output is the fastest way to understand why a query is slow.
PostgreSQL EXPLAIN ANALYZE output
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
-- Without index:
Seq Scan on orders (cost=0.00..18334.00 rows=1 width=52)
(actual time=128.3..128.3 rows=1 loops=1)
Filter: (customer_id = 42)
Rows Removed by Filter: 999999 <- scanned 1M rows!
Planning Time: 0.1 ms
Execution Time: 128.4 ms
-- After: CREATE INDEX idx_orders_customer ON orders(customer_id);
Index Scan on orders using idx_orders_customer
(cost=0.42..8.44 rows=1 width=52)
(actual time=0.04..0.04 rows=1 loops=1)
Index Cond: (customer_id = 42)
Planning Time: 0.1 ms
Execution Time: 0.05 ms <- 2,500x fasterFull table scan. Look for this when performance is bad.
B-tree lookup. Usually what you want.
Covering index hit. Best case: no heap fetch.
Common slow query patterns
Problematic
# Django ORM, looks clean, performs terribly
posts = Post.objects.all() # 1 query
for post in posts:
# Each iteration fires a NEW query!
author = post.author # N queries
comments = post.comments.all() # N more queries
# For 100 posts: 201 queries sent to the databaseFixed
# Use eager loading to fetch in bulk
posts = Post.objects.select_related('author') \
.prefetch_related('comments') \
.all()
# Now: exactly 3 queries total, regardless of post count:
# 1. SELECT posts
# 2. SELECT authors WHERE id IN (...)
# 3. SELECT comments WHERE post_id IN (...)The N+1 pattern is the most common cause of slow pages in ORM-based applications. Enable query logging in development to catch it early.
Problematic
-- Index on created_at exists, but this doesn't use it:
SELECT * FROM orders
WHERE YEAR(created_at) = 2025;
-- Or in PostgreSQL:
WHERE DATE_TRUNC('year', created_at) = '2025-01-01';
-- The function wraps the column, breaking the index.
-- Database falls back to full scan.Fixed
-- Rewrite to a range query on the raw column: SELECT * FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01'; -- Now the B-tree index on created_at is used. -- Alternative: create an expression index CREATE INDEX idx_year ON orders (YEAR(created_at));
If you must apply a function, create an expression index that stores the pre-computed result.
Problematic
-- Index on name exists, but this causes a full scan: SELECT * FROM users WHERE name LIKE '%smith'; -- The leading wildcard prevents the B-tree from -- knowing where to start. Every row must be checked.
Fixed
-- Trailing wildcard works (prefix search): SELECT * FROM users WHERE name LIKE 'smith%'; -- For full-text search, use the right tool: -- PostgreSQL: tsvector + GIN index -- MySQL: FULLTEXT index -- Or: Elasticsearch for advanced text search
B-trees are sorted by value from left to right. A leading wildcard means 'start could be anywhere', making the sorted order useless.
When not to add an index
Low cardinality columns
A boolean column has only 2 values. An index on it often makes things worse: the database reads half the table and then fetches each row anyway.
Small tables
For tables under a few thousand rows, a sequential scan is faster than an index lookup. The overhead of the index traversal exceeds the benefit.
Write-heavy tables
Every write must update all indexes. An event log or audit table with 20 indexes will be throttled by index maintenance, not by the data itself.
Columns rarely in WHERE
Index only columns your queries actually filter on. An index that is never used still costs write overhead and storage.