26.6 Indexing
Table of Contents
Why Indexes Matter for Performance
Indexes are one of the most powerful tools for speeding up database queries. Used correctly, they can make a slow query thousands of times faster. Used wrongly, they can slow down writes and waste memory.
This chapter focuses on indexing from a performance and scalability perspective. It assumes you already know basic database concepts and what an index is at a high level.
Key idea: An index lets the database find rows without scanning the whole table. You trade extra storage and slower writes for much faster reads.
How Indexes Affect Query Performance
Full table scan vs indexed lookup
Imagine a users table with 10 million rows.
SELECT * FROM users WHERE email = 'alice@example.com';
Without an index on email, the database must:
- Start at the first row.
- Check
emailvalue. - Move to the next row.
- Repeat until it finds matching rows or reaches the end.
This is a full table scan. Time grows roughly with the number of rows, $O(n)$.
With an index on email:
CREATE INDEX idx_users_email ON users (email);The database can:
- Search the index structure (for example a B-tree) to find the right position.
- Jump directly to the matching row(s).
This lookup is typically $O(\log n)$ and involves far fewer disk pages.
Very roughly:
| Rows | Full Scan (no index) | Indexed Lookup (B-tree) |
|---|---|---|
| 1,000 | ~1,000 checks | ~10 checks |
| 1,000,000 | ~1,000,000 checks | ~20 checks |
| 100M | ~100,000,000 checks | ~27 checks |
The exact numbers vary, but the pattern is stable: indexes scale much better as data grows.
Selectivity and cardinality
Indexes are fastest when they are selective, meaning they greatly reduce the number of rows the database must inspect.
- High cardinality column
Many distinct values, for exampleemail,id,order_id.
Good index candidate. - Low cardinality column
Few distinct values, for examplestatuswith valuesNEW,PAID,CANCELLED.
A plain index might not be as useful, because many rows share the same value.
Example:
SELECT * FROM orders WHERE status = 'PAID';
If 90% of orders are PAID, the index on status will not filter much. The database might choose a table scan instead, because jumping around through the index and table can be slower than a simple sequential scan.
Rule: Index columns that significantly reduce the number of rows to scan. High selectivity makes indexes effective.
Reading Queries Through the Lens of Indexes
Identifying the “filter columns”
Look at a query and mark the parts that filter rows:
SELECT id, email
FROM users
WHERE country = 'US'
AND is_active = true
AND created_at >= '2024-01-01'
ORDER BY created_at DESC
LIMIT 20;- Filter conditions:
country,is_active,created_at. - Sort:
ORDER BY created_at DESC. - Limit:
LIMIT 20.
Indexing goals:
- Help the database filter quickly by the
WHEREclause. - Help it avoid a sort for
ORDER BYif possible. - Combine both so it can find the top 20 rows quickly.
A possible composite index:
CREATE INDEX idx_users_country_active_created
ON users (country, is_active, created_at DESC);This index can:
- Filter by
countryandis_active. - Already be ordered by
created_at DESC. - Allow the database to stop early after grabbing 20 rows.
Range queries and indexes
Range conditions like >=, >, <, BETWEEN use indexes efficiently when they appear at the end of an index.
Example:
SELECT *
FROM orders
WHERE customer_id = 123
AND created_at >= '2024-01-01';Index:
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at);
This index structure is grouped by customer_id. Inside each group, rows are ordered by created_at. To handle the query:
- Find the first index entry with
customer_id = 123andcreated_at >= '2024-01-01'. - Scan forward until
customer_idchanges.
Very few rows are touched if the user has few orders.
If you reversed the index:
CREATE INDEX idx_orders_created_customer
ON orders (created_at, customer_id);This is worse for the same query, because:
- The index is grouped by
created_at, not bycustomer_id. - The database would have to scan many different
customer_idvalues.
Rule: For composite indexes, put equality filters first, then range filters.
How Indexes Affect Writes and Storage
Indexes improve read performance, but they are not free.
Inserts, updates, and deletes
When you:
INSERT INTO users (id, email) VALUES (...);the database must:
- Insert the row into the table.
- Update every index that touches
idoremail.
Similarly, when you UPDATE or DELETE rows, indexes must be maintained.
Effects:
- More CPU work per write.
- More disk I/O for index pages.
- Potentially more locking or contention in high write workloads.
A table with 10 indexes will have much slower inserts than a table with 2 well chosen indexes.
Rule: Indexes speed up reads, but slow down writes. Do not index every column by default.
Storage and memory
Indexes take disk space and memory.
- Disk: large tables with many indexes can use more disk space in indexes than in the base table.
- Memory (buffer cache): index pages must be cached in RAM, competing with table data.
On a busy system, extra indexes can:
- Evict useful data from cache.
- Increase disk reads.
- Hurt overall performance.
Index maintenance over time
Indexes also influence:
- Vacuum and autovacuum in databases like PostgreSQL.
- Rebuilds or defragmentation in some systems.
- Backup size and time.
From a scalability point of view, too many or poorly designed indexes can become a serious operational cost.
Types of Indexes and When to Use Them
Different database engines have different index types. Here we stay at a conceptual level.
B-tree indexes
Most common default index type.
Good for:
- Equality searches:
WHERE email = '...' - Range searches:
WHERE created_at BETWEEN ... - Sorting:
ORDER BY created_at
Most of your indexes in OLTP (online transaction processing) systems will be B-tree like structures.
Hash indexes
Optimized for equality checks, for example WHERE session_token = 'xyz'.
Limitations often include:
- Not useful for range queries.
- Sometimes less flexible than B-tree.
- Sometimes not WAL logged or durable in the same way, depending on database.
For many workloads B-tree is still the best default.
Covering indexes
A covering index is an index that contains all the columns needed for a query, so the database does not have to read the base table at all.
Example query:
SELECT id, email
FROM users
WHERE email LIKE 'alice%@example.com';If the index is:
CREATE INDEX idx_users_email
ON users (email);The database:
- Uses the index to find matching
emailvalues. - Then reads the table to fetch
idfor each row (a "bookmark" lookup).
If you create a covering index:
-- Syntax varies by database, concept is what matters
CREATE INDEX idx_users_email_id
ON users (email, id);
Now, the index has both email and id. The database might satisfy the entire query from the index, which saves I/O.
Covering indexes are especially useful for frequent, performance critical queries that read few columns.
Rule: Use covering indexes for hot, simple queries where avoiding table lookups is worth extra index storage.
Designing Composite Indexes for Performance
Index column order
For an index on (a, b, c):
- The database can efficiently filter on:
aa, ba, b, c- But not directly on:
balonecaloneb, cwithouta
This is sometimes called the left prefix rule.
Example:
CREATE INDEX idx_orders_customer_status_created
ON orders (customer_id, status, created_at);Useful for queries like:
-- 1. Uses (customer_id)
SELECT * FROM orders WHERE customer_id = 123;
-- 2. Uses (customer_id, status)
SELECT * FROM orders WHERE customer_id = 123 AND status = 'PAID';
-- 3. Uses (customer_id, status, created_at)
SELECT * FROM orders
WHERE customer_id = 123 AND status = 'PAID'
ORDER BY created_at DESC;But less useful for:
-- Cannot fully use the index, no customer_id
SELECT * FROM orders WHERE status = 'PAID';So choose the column order based on actual query patterns, not just "what seems important."
Combining WHERE and ORDER BY
Try to support both filtering and sorting with one index.
Example query:
SELECT *
FROM products
WHERE category_id = 5
ORDER BY price ASC
LIMIT 50;Index:
CREATE INDEX idx_products_category_price
ON products (category_id, price);Benefits:
- Filter by
category_id. - Already sorted by
pricewithin that category. - Database can scan until it collects 50 rows, then stop.
If you instead only had:
CREATE INDEX idx_products_category ON products (category_id);The database:
- Uses the index to find all products with
category_id = 5. - Collects them, then sorts them by
price. - Returns the top 50.
This is slower, especially for large categories.
Multiple indexes vs one composite index
Sometimes you might think:
CREATE INDEX idx_users_country ON users (country);
CREATE INDEX idx_users_is_active ON users (is_active);for:
SELECT * FROM users
WHERE country = 'US' AND is_active = true;Whether this is good or not depends on the database. Some can combine indexes, using both. But usually a single composite index is more effective:
CREATE INDEX idx_users_country_is_active
ON users (country, is_active);The composite index keeps the combination of values together, often reducing work.
Indexing Patterns for Common Workloads
Point lookups
Use case: Find a single row by unique key.
Example:
SELECT * FROM users WHERE id = 42;Backend pattern: Getting a user by ID for authentication, showing a profile, etc.
Index design:
- A primary key index on
id. - Effectively always present on
idif it is the primary key.
Another example:
SELECT * FROM users WHERE email = 'alice@example.com';
If email must be unique:
CREATE UNIQUE INDEX idx_users_email ON users (email);Use unique indexes to:
- Enforce business rules.
- Get very fast lookups.
Recent data queries
Use case: "Latest N" items, like last 100 orders, last 50 log entries.
Example:
SELECT *
FROM logs
ORDER BY created_at DESC
LIMIT 100;Index:
CREATE INDEX idx_logs_created_at_desc
ON logs (created_at DESC);The database can:
- Scan the index from the newest entries.
- Stop after 100 rows.
- Potentially avoid scanning the full table.
Very common in dashboards and activity feeds.
Time range queries
Use case: Data for a given time window.
SELECT *
FROM orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31';Index:
CREATE INDEX idx_orders_created_at
ON orders (created_at);With additional filters:
SELECT *
FROM orders
WHERE customer_id = 123
AND created_at >= '2024-01-01'
AND created_at < '2024-02-01';Index:
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at);Pagination
Pattern:
SELECT *
FROM products
WHERE category_id = 7
ORDER BY id
LIMIT 20 OFFSET 1000;Offset-based pagination can be slow because the database must skip and count many rows.
Better pattern for performance: keyset pagination (also called cursor-based):
-- First page
SELECT *
FROM products
WHERE category_id = 7
ORDER BY id
LIMIT 20;
-- Next page, use last id from previous page
SELECT *
FROM products
WHERE category_id = 7
AND id > :last_id
ORDER BY id
LIMIT 20;Index:
CREATE INDEX idx_products_category_id
ON products (category_id, id);
This index lets the database jump directly to category_id = 7 and id > last_id and then scan forward.
From a backend perspective, designing pagination that works well with indexes is crucial for scalability.
Avoiding Common Indexing Pitfalls
Indexing every column
It is easy to think: "Indexes make queries faster, so let us add one on every column."
Problems:
- Write performance drops.
- Storage grows quickly.
- Query planner has too many choices, potential suboptimal plans.
- Maintenance overhead increases.
Instead:
- Start with essential indexes: primary keys, foreign keys, and a few critical query indexes.
- Measure performance.
- Add indexes based on evidence.
Ignoring the query planner
Relational databases have tools to show query plans, such as EXPLAIN in PostgreSQL and MySQL.
Example:
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'alice@example.com';This shows:
- Whether an index is used.
- If it is doing a sequential scan instead.
- How many rows are touched.
- Estimated vs actual cost.
Use these tools to:
- Confirm that your index is effective.
- Discover missing indexes.
- See when an index is not selective enough.
Functions on indexed columns
This overlaps with more detailed SQL topics, but the performance idea is:
SELECT *
FROM users
WHERE LOWER(email) = 'alice@example.com';
If you have an index on email, the database might not use it, because LOWER(email) prevents direct index lookup.
Possible fix:
- Use a functional index if supported, for example an index on
LOWER(email). - Or store normalized values (e.g. lowercase emails) and index them directly.
Overlapping indexes
Example:
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);The first index is mostly covered by the second. You might not need both.
Too many overlapping indexes:
- Waste disk and memory.
- Slow writes for little gain.
Regularly review your index list and remove unused or redundant ones.
Monitoring and Evolving Index Strategy
Measuring index usage
Production databases expose statistics:
- How often an index is scanned.
- How many rows it returns.
- How many index-only scans occur.
- How many table scans happen without using an index.
By monitoring:
- You can find unused indexes to drop.
- You can find hot queries with missing indexes.
- You can detect regressions after schema changes.
Adapting to changing query patterns
As your backend grows:
- New features add new queries.
- Old queries are no longer used.
- Traffic patterns change.
Your index strategy should evolve:
- When adding new features, think about their queries and necessary indexes.
- Periodically review index usage in production.
- Clean up obsolete indexes.
Treat indexes as part of your performance-focused API design, not as a one-time database detail.
Practical workflow
A simple workflow when debugging a slow query:
- Get the exact SQL query the application sends.
- Run
EXPLAINor equivalent to see the plan. - Check if a full table scan is happening.
- Identify filter, join, and sort columns.
- Design a candidate index that helps with the biggest cost.
- Create index in a test or staging environment.
- Re-run
EXPLAINand measure execution time. - If improved, roll out carefully to production.
Over time, this workflow becomes a normal part of backend performance tuning.
Summary
Indexes are a central tool in backend performance and scalability:
- They let databases find rows efficiently without scanning entire tables.
- They are most effective when they are selective and match real query patterns.
- Composite indexes should be designed with filtering, sorting, and pagination in mind.
- Indexes come with costs: slower writes, more storage, and operational overhead.
- Good performance engineering includes measuring, planning indexes around real queries, and regular maintenance.
By understanding indexing at this practical level, you can design schemas and queries that scale far better as your application and data grow.
Views: 6
KAHIBARO