Loading content...
If there's one lever that has the most dramatic impact on SQL query performance, it's indexing. A well-designed index can transform a 10-minute query into a 10-millisecond query—a 60,000x improvement. Conversely, missing or poorly-designed indexes can bring production databases to their knees.
Yet indexing is widely misunderstood. Many developers either avoid indexes ("they slow down writes") or over-index ("more indexes = faster queries"). Both approaches are wrong. Effective indexing requires understanding how indexes work, when they help, and what makes them optimal for specific query patterns.
This page will give you that understanding—from the internals that explain index behavior to practical strategies for index design in production systems.
By the end of this page, you will understand: how B-tree indexes work internally; when indexes help vs. hurt; how to design composite indexes for complex queries; covering indexes and index-only scans; partial and functional indexes for advanced use cases; and a systematic approach to index design.
An index is an auxiliary data structure that accelerates data retrieval at the cost of additional storage and write overhead. Understanding index internals reveals why certain query patterns benefit from indexes while others don't.
The B-Tree: Foundation of Relational Indexes
The vast majority of database indexes use the B-tree (or B+tree) data structure. B-trees are balanced tree structures optimized for systems that read and write large blocks of data—exactly how databases access storage.
B-tree properties:
Implications:
A B-tree index on 10 million rows typically has only 3-4 levels. Finding any specific row requires reading 3-4 index pages, regardless of table size. For comparison, a binary search tree would require log₂(10M) ≈ 24 comparisons—and potentially 24 random I/O operations.
| Rows | Approximate Depth | Pages to Read | Time (SSD) |
|---|---|---|---|
| 10,000 | 2 | 2-3 | <0.1ms |
| 1,000,000 | 3 | 3-4 | 0.2ms |
| 100,000,000 | 4 | 4-5 | 0.3ms |
| 10,000,000,000 | 5 | 5-6 | 0.4ms |
The Leaf Level Contains Everything:
In a B+tree (the variant used by most databases), all actual data pointers are stored in leaf nodes. Internal nodes contain only keys for navigation. Leaf nodes are linked together, enabling efficient sequential scans through sorted data.
What the index stores:
Each leaf entry contains:
This pointer is how the database locates the actual row after finding it in the index.
In a clustered index (SQL Server, InnoDB), the table data IS the leaf level—rows are physically stored in index order. Each table has at most one clustered index. Non-clustered indexes store pointers to the clustered index key or row location. In PostgreSQL, all indexes are non-clustered; the table (heap) is separate from indexes.
Indexes are not universally beneficial. Understanding when they help—and when they actually hurt performance—is crucial for effective index design.
The Selectivity Threshold:
The critical factor is selectivity—what percentage of rows match the filter. For typical tables:
| Selectivity | Rows Returned | Best Access Method |
|---|---|---|
| < 1% | Few rows | Index Scan |
| 1-5% | Some rows | Index Scan or Bitmap Scan |
| 5-15% | Many rows | Bitmap Scan |
| > 15% | Most rows | Sequential Scan |
These thresholds vary by table width, clustering, and hardware. On SSDs, the crossover point is higher because random I/O is less penalized.
Why sequential scans beat indexes for low selectivity:
An index scan involves:
For 40% of a million-row table, that's 400,000 random table page reads. A sequential scan reads every page once, in order—which disk and OS can prefetch efficiently. Sequential I/O at 500MB/s beats random I/O at 20MB/s.
Adding an index can make queries slower if the optimizer chooses it incorrectly. If statistics suggest high selectivity but actual data is low selectivity, the optimizer chooses an index scan that performs worse than a sequential scan. This is why accurate statistics are essential.
The Write Overhead Reality:
Every index imposes overhead on data modification:
For a table with 5 indexes, an INSERT does 6x the write work (1 table + 5 indexes). This overhead can be significant for write-heavy tables.
Rule of thumb:
Real-world queries rarely filter on a single column. Composite indexes—indexes on multiple columns—are essential for optimizing complex queries. However, their design requires careful consideration of column order and usage patterns.
The Left-to-Right Rule:
A B-tree composite index on columns (A, B, C) sorts entries first by A, then by B within each A value, then by C within each (A, B) combination.
This index can efficiently satisfy:
This index CANNOT efficiently satisfy:
The reason: B-tree lookup requires starting from the leftmost column. You can't jump to the middle of a sorted sequence without scanning from the start.
12345678910111213141516171819202122232425262728
-- Table: orders (id, customer_id, status, created_at, total) -- Query 1: Filter by customer, sort by dateSELECT * FROM orders WHERE customer_id = 123 ORDER BY created_at DESC LIMIT 10; -- Optimal index: (customer_id, created_at DESC)-- Why: Filter on customer_id, then sorted access for ORDER BY -- Query 2: Filter by customer and statusSELECT * FROM orders WHERE customer_id = 123 AND status = 'pending'; -- Optimal index: (customer_id, status)-- OR: (status, customer_id) if status is more selective -- Query 3: Filter by customer, status, sort by dateSELECT * FROM orders WHERE customer_id = 123 AND status = 'pending'ORDER BY created_at DESC; -- Optimal index: (customer_id, status, created_at DESC)-- Why: Equality filters first, then sort column -- Anti-pattern: (status, created_at, customer_id)-- This can't efficiently filter on customer_id= conditions should be leftmost; they provide exact navigation to the matching subset.<, >, BETWEEN, LIKE 'prefix%' should come after equality columns; range scan ends sorted access for remaining columns.A phone book is indexed by (Last Name, First Name). You can quickly find 'Smith, John' or all 'Smith' entries. But finding all 'John' entries requires scanning the entire book—you can't jump to 'John' without knowing the last name first. Same principle applies to composite indexes.
A covering index includes all columns that a query needs, allowing the database to satisfy the query entirely from the index without accessing the table. This is the fastest possible access pattern.
Why Covering Indexes Are So Fast:
Example scenario:
-- Query
SELECT customer_id, status, COUNT(*)
FROM orders
WHERE status = 'pending'
GROUP BY customer_id, status;
-- Non-covering index: (status)
-- Plan: Index Scan on idx_status → Fetch rows from table → Aggregate
-- 10,000 index entries → 10,000 random table reads
-- Covering index: (status, customer_id)
-- Plan: Index Only Scan → Aggregate
-- 10,000 index entries scanned sequentially, no table access
The covering index version can be 10-100x faster, especially on cold cache.
| Database | Syntax | Notes |
|---|---|---|
| PostgreSQL | CREATE INDEX idx ON tbl (key_cols) INCLUDE (other_cols) | INCLUDE columns are leaf-only; don't affect sort order |
| SQL Server | CREATE INDEX idx ON tbl (key_cols) INCLUDE (other_cols) | Same as PostgreSQL; INCLUDE columns in leaf |
| MySQL/InnoDB | CREATE INDEX idx ON tbl (key_cols, other_cols) | No INCLUDE syntax; add columns to index key |
| Oracle | CREATE INDEX idx ON tbl (key_cols, other_cols) | No INCLUDE syntax; add columns to index key |
The INCLUDE Advantage:
PostgreSQL and SQL Server's INCLUDE clause has a significant advantage: included columns are stored only in leaf nodes, not in internal nodes. This means:
When to use INCLUDE:
Covering indexes can become very large if you include many columns or wide columns (VARCHAR, TEXT). A covering index that approaches the table size provides little benefit—you're essentially duplicating data. Balance coverage against size. For frequently-run queries on hot paths, the trade-off is usually worthwhile.
A partial index (PostgreSQL) or filtered index (SQL Server) indexes only rows that match a specified condition. This powerful technique reduces index size and maintenance overhead while still accelerating the targeted queries.
123456789101112131415161718
-- PostgreSQL: Partial Index-- Only index active users (5% of table)CREATE INDEX idx_users_active ON users (email) WHERE is_active = true; -- Only queries with WHERE is_active = true can use this indexSELECT * FROM users WHERE email = 'test@example.com' AND is_active = true; -- Uses indexSELECT * FROM users WHERE email = 'test@example.com'; -- Cannot use index -- SQL Server: Filtered IndexCREATE NONCLUSTERED INDEX idx_orders_pending ON orders (customer_id, created_at)WHERE status = 'pending'; -- Oracle: Function-based approach (no true partial indexes)CREATE INDEX idx_orders_pending ON orders ( CASE WHEN status = 'pending' THEN customer_id END);Benefits of Partial Indexes:
| Benefit | Impact |
|---|---|
| Smaller size | Indexing 5% of rows = 5% of full index size |
| Faster writes | INSERT/UPDATE only touches index if row matches predicate |
| Better cache efficiency | More of the useful index fits in memory |
| Faster maintenance | VACUUM/REINDEX operates on smaller structure |
Limitations:
The query's WHERE clause must match or imply the index predicate. For an index WHERE status = 'pending', a query with WHERE status IN ('pending', 'active') cannot use it—the query might return 'active' rows not in the index. Design partial indexes around exact query patterns.
Sometimes queries filter on expressions or function results rather than raw column values. Functional indexes (also called expression indexes) index the computed result, enabling efficient lookups on transformed data.
123456789101112131415161718192021222324252627
-- PostgreSQL: Case-insensitive searchCREATE INDEX idx_users_email_lower ON users (LOWER(email)); -- Query that uses the index:SELECT * FROM users WHERE LOWER(email) = 'test@example.com'; -- PostgreSQL: JSON field extractionCREATE INDEX idx_data_type ON events ((data->>'type')); -- Query:SELECT * FROM events WHERE data->>'type' = 'purchase'; -- PostgreSQL: Date truncation for time-based groupingCREATE INDEX idx_orders_month ON orders (DATE_TRUNC('month', created_at)); -- Query grouping by month uses this:SELECT DATE_TRUNC('month', created_at), COUNT(*) FROM orders GROUP BY DATE_TRUNC('month', created_at); -- SQL Server: Computed column with indexALTER TABLE users ADD email_lower AS LOWER(email) PERSISTED;CREATE INDEX idx_email_lower ON users (email_lower); -- MySQL: Generated column with indexALTER TABLE users ADD email_lower VARCHAR(255) GENERATED ALWAYS AS (LOWER(email)) STORED;CREATE INDEX idx_email_lower ON users (email_lower);Critical Constraint: Exact Match Required
The query expression must exactly match the index expression. Consider:
CREATE INDEX idx ON users (LOWER(email));
-- Uses index:
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- Does NOT use index (different function):
SELECT * FROM users WHERE UPPER(email) = 'TEST@EXAMPLE.COM';
-- Does NOT use index (different expression):
SELECT * FROM users WHERE LOWER(TRIM(email)) = 'test@example.com';
The optimizer doesn't reason about function equivalences. If your code uses different variations of the same logical operation, you'll need multiple indexes or standardized function usage.
Functions used in expression indexes must be IMMUTABLE—they must always return the same output for the same input. Functions that depend on locale, time zone, or other external state cannot be indexed. NOW(), RANDOM(), and locale-dependent functions are not allowed.
Indexes require ongoing maintenance. Without it, they bloat, fragment, and provide diminishing returns. Effective index management is as important as initial index design.
Index Bloat:
As rows are updated and deleted, index entries are marked dead but not immediately removed. This creates bloat—wasted space in the index. Symptoms:
Solutions by database:
| Database | Automatic Solution | Manual Solution |
|---|---|---|
| PostgreSQL | autovacuum (background) | VACUUM, REINDEX |
| MySQL | Background purge | OPTIMIZE TABLE, ALTER TABLE ENGINE=InnoDB |
| SQL Server | Ghost cleanup | ALTER INDEX REBUILD/REORGANIZE |
| Oracle | Automatic segment space management | ALTER INDEX REBUILD |
1234567891011121314151617181920212223242526272829303132333435
-- PostgreSQL: Check index bloatSELECT schemaname, tablename, indexname, pg_relation_size(indexrelid) AS index_size, idx_scan AS index_scans, idx_tup_read AS tuples_read, idx_tup_fetch AS tuples_fetchedFROM pg_stat_user_indexesORDER BY pg_relation_size(indexrelid) DESC; -- PostgreSQL: Find unused indexesSELECT schemaname || '.' || indexrelname AS index, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS sizeFROM pg_stat_user_indexesWHERE idx_scan = 0 -- Never used AND indexrelid NOT IN ( SELECT conindid FROM pg_constraint -- Not a constraint )ORDER BY pg_relation_size(indexrelid) DESC; -- MySQL: Index usage statisticsSELECT object_schema, object_name, index_name, count_star AS rows_examined, count_read, count_fetchFROM performance_schema.table_io_waits_summary_by_index_usageWHERE index_name IS NOT NULLORDER BY count_star DESC;Query patterns change over time. An index critical during a feature launch may become unused after user behavior shifts. Schedule quarterly reviews of index usage statistics and remove unused indexes. This reduces write overhead and storage costs.
Index design should be systematic, not ad-hoc. Following a consistent methodology ensures you create indexes that serve your actual query patterns while minimizing overhead.
The Index Consolidation Pattern:
Multiple queries can often share a single well-designed index:
-- Query A: WHERE customer_id = 1 AND status = 'active'
-- Query B: WHERE customer_id = 1 ORDER BY created_at DESC
-- Query C: WHERE customer_id = 1 AND status = 'active' ORDER BY created_at DESC
-- Instead of three indexes, one composite index serves all:
CREATE INDEX idx_customer_orders ON orders (customer_id, status, created_at DESC);
This index efficiently supports all three patterns because:
It's easier to add an index later than to remove one that application logic depends on. Start with indexes for the most critical queries, monitor performance, and add more only when specific performance problems are identified. Over-indexing from day one creates maintenance burden and write overhead.
What's Next:
With a solid foundation in index design, the next page covers Query Rewriting—techniques for restructuring SQL queries to achieve better execution plans and performance.
You now have comprehensive knowledge of index design—from B-tree fundamentals through advanced techniques like covering, partial, and functional indexes. This knowledge enables you to make informed decisions about when, where, and how to index for optimal performance.