Overview
Indexes are the single biggest lever for query performance. A well-designed index turns a multi-second scan into a millisecond lookup; a poorly chosen one wastes disk space and slows down writes. This tutorial explains how indexes work and how to choose them using real query plans.
How B-Tree Indexes Work
Most relational databases use a B-tree index. It stores keys in sorted order so the engine can find a value in O(log n) instead of scanning every row.
| Operation | Without index | With B-tree index |
|---|---|---|
| Equality lookup | O(n) full scan | O(log n) |
| Range scan | O(n) | O(log n + k) |
| Sort | Sort all rows | Read in order |
| Insert | O(1) | O(log n) plus index maintenance |
The trade-off is that every index adds write cost. A table with ten indexes writes eleven structures on every insert.
The Query Plan Is the Ground Truth
Never guess which index will help. Ask the database.
-- PostgreSQL
EXPLAIN ANALYZE
SELECT id, title FROM posts WHERE author_id = 42 ORDER BY created_at DESC LIMIT 10;
-- MySQL
EXPLAIN ANALYZE
SELECT id, title FROM posts WHERE author_id = 42 ORDER BY created_at DESC LIMIT 10;
| Plan node | Meaning |
|---|---|
Seq Scan / ALL |
Full table scan — usually the problem |
Index Scan |
Reads rows via index |
Index Only Scan |
All data from the index — fastest |
Bitmap Heap Scan |
Combines multiple index matches |
Sort |
Sorting rows, which could be avoided by an index |
Look for the rows estimate versus actual, and for a Sort node that an index could eliminate.
Single-Column Index
CREATE INDEX idx_posts_author ON posts (author_id);
Good for filtering on one column. Not sufficient when the query filters on several columns, sorts by another, or selects columns not in the index.
Composite Indexes
A composite index covers multiple columns in order. The order matters and must match the query's access pattern.
CREATE INDEX idx_posts_author_created ON posts (author_id, created_at DESC);
This index supports:
-- Filter by author_id, sorted by created_at
SELECT id FROM posts WHERE author_id = 42 ORDER BY created_at DESC LIMIT 10;
-- Filter by author_id only
SELECT id FROM posts WHERE author_id = 42;
It does not support:
-- Filter by created_at only — the leading column is missing
SELECT id FROM posts WHERE created_at > now() - interval '7 days';
The Leftmost Prefix Rule
An index on (a, b, c) can be used for queries on (a), (a, b), and (a, b, c), but not (b), (c), or (b, c) alone.
| Query filter | Uses index on (a, b, c)? |
|---|---|
WHERE a = 1 |
Yes |
WHERE a = 1 AND b = 2 |
Yes |
WHERE a = 1 AND b = 2 AND c = 3 |
Yes |
WHERE b = 2 |
No |
WHERE a = 1 AND c = 3 |
Partially — only on a |
WHERE b = 2 AND c = 3 |
No |
Equality Before Range
Columns used in equality comparisons should come before columns used in range comparisons.
-- Query
SELECT * FROM orders
WHERE customer_id = 42 AND status = 'paid' AND created_at > '2026-01-01';
-- Correct index order: equality columns first, then range
CREATE INDEX idx_orders_lookup ON orders (customer_id, status, created_at);
If the range column comes first, the database cannot use the trailing equality column efficiently.
Covering Indexes
A covering index includes all columns the query needs, so the engine never touches the table.
-- Query
SELECT author_id, created_at, title FROM posts WHERE author_id = 42;
-- Covering index
CREATE INDEX idx_posts_covering ON posts (author_id, created_at) INCLUDE (title);
PostgreSQL and SQL Server support INCLUDE for non-key columns. MySQL achieves the same effect by adding the columns to the key list, though that increases index size.
Check the plan for Index Only Scan (PostgreSQL) or Using index (MySQL) to confirm the table was skipped.
Partial Indexes
Index only the rows you actually query. Smaller index, faster writes.
-- Only 2% of orders are pending
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';
This index is used only when the query includes the same predicate:
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at;
Partial indexes are especially effective for soft-delete patterns:
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;
Expression Indexes
When a query filters on a function of a column, a plain index on the column is not used. Index the expression instead.
-- Query
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
-- Expression index
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
Alternatively, store the normalized value in a separate column and index that. It is often simpler and more predictable.
Unique Indexes
CREATE UNIQUE INDEX idx_users_email ON users (email);
A unique index enforces data integrity and doubles as a lookup index. Prefer it over a separate UNIQUE constraint plus a plain index.
What Kills Index Usage
| Pattern | Problem | Rewrite |
|---|---|---|
WHERE YEAR(created_at) = 2026 |
Function on the column | WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' |
WHERE name LIKE '%smith%' |
Leading wildcard | Full-text index or trigram index |
WHERE status != 'done' |
Negation matches most rows | Partial index on the desired statuses |
WHERE id IN (SELECT ...) with a large subquery |
Optimizer chooses a scan | Rewrite as a JOIN |
WHERE varchar_col = 123 |
Implicit type cast | Match the literal type to the column type |
ORDER BY random() |
No index can help | Precompute a random column and index it |
Index Bloat and Maintenance
Frequent updates and deletes leave dead entries in the index. Over time, the index grows and performance degrades.
-- PostgreSQL: check bloat
SELECT
schemaname, relname AS table_name, indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
-- Rebuild
REINDEX INDEX CONCURRENTLY idx_posts_author;
Use CONCURRENTLY to avoid locking the table. Monitor index usage with pg_stat_user_indexes.idx_scan; an index with zero scans is dead weight.
Design Process
- Identify the slowest queries from
pg_stat_statementsor the slow query log. - Run
EXPLAIN ANALYZEon each to find the bottleneck. - Design a composite index where equality columns come first, then the range or sort column.
- Add
INCLUDEcolumns if the query is read-heavy and can be fully covered. - Consider a partial index if the query targets a small subset of rows.
- Re-run
EXPLAIN ANALYZEto confirm the plan changed. - Drop indexes that show zero usage after a representative period.
Anti-Patterns
- Indexing every column. Slows writes and confuses the optimizer.
- Duplicate indexes.
(a)and(a, b)overlap; the single-column index is often redundant. - Leading range columns in a composite index. Prevents using trailing equality columns.
- Relying on index hints. Fix the query or the index instead.
- Adding an index without measuring. Always compare plans before and after.
