Database Indexing Strategies: Covering and Partial Indexes
Most developers understand basic indexing: add an index on frequently queried columns, and queries get faster. But production databases demand more sophisticated strategies. Every index you create...
Key Insights
- Covering indexes eliminate expensive table lookups by including all query columns directly in the index, enabling index-only scans that can be 3-10x faster for read-heavy workloads
- Partial indexes reduce storage overhead and write costs by indexing only relevant row subsets—a partial index on active users can be 95% smaller than a full index while serving 99% of queries
- Combining both strategies creates highly optimized indexes for specific query patterns, but requires careful analysis of your actual query workload to avoid over-indexing
Introduction to Index Optimization
Most developers understand basic indexing: add an index on frequently queried columns, and queries get faster. But production databases demand more sophisticated strategies. Every index you create costs storage space and slows down writes. The real skill lies in building indexes that maximize query performance while minimizing overhead.
Two advanced indexing techniques stand out for their practical impact: covering indexes and partial indexes. Covering indexes eliminate table lookups entirely by storing all needed data in the index structure itself. Partial indexes reduce bloat by indexing only the rows that actually matter. Master these techniques, and you’ll handle query optimization challenges that stump most developers.
Let’s start with a baseline. Here’s a typical query against an orders table:
EXPLAIN ANALYZE
SELECT order_id, customer_id, total_amount, created_at
FROM orders
WHERE status = 'completed'
AND created_at > NOW() - INTERVAL '30 days';
Without proper indexing, you’ll see something like this:
Seq Scan on orders (cost=0.00..18334.00 rows=12500 width=32) (actual time=0.045..156.234 rows=12483)
Filter: ((status = 'completed') AND (created_at > (now() - '30 days'::interval)))
Rows Removed by Filter: 487517
Planning Time: 0.123 ms
Execution Time: 157.891 ms
A sequential scan reading 500,000 rows to return 12,500. This is where strategic indexing transforms performance.
Covering Indexes Explained
A covering index contains all columns needed to satisfy a query—not just the columns in the WHERE clause, but also those in the SELECT list. When PostgreSQL can answer a query entirely from index data, it performs an “index-only scan” without touching the table heap at all.
This matters because table lookups are expensive. Each row access requires additional I/O, and scattered reads destroy cache efficiency. Covering indexes turn random access patterns into sequential index scans.
Here’s how to create a covering index in PostgreSQL:
CREATE INDEX idx_orders_covering ON orders (status, created_at)
INCLUDE (order_id, customer_id, total_amount);
The INCLUDE clause adds columns to the index leaf nodes without making them part of the search key. This keeps the index tree compact while providing all necessary data.
Now run the same query:
Index Only Scan using idx_orders_covering on orders (cost=0.42..425.67 rows=12500 width=32) (actual time=0.028..8.234 rows=12483)
Index Cond: ((status = 'completed') AND (created_at > (now() - '30 days'::interval)))
Heap Fetches: 0
Planning Time: 0.089 ms
Execution Time: 9.123 ms
Execution time dropped from 157ms to 9ms—a 17x improvement. The “Heap Fetches: 0” confirms no table access occurred.
For MySQL, the syntax differs slightly:
CREATE INDEX idx_orders_covering ON orders (status, created_at, order_id, customer_id, total_amount);
MySQL doesn’t separate key columns from included columns, so all columns become part of the index key. This works but can make the index larger than PostgreSQL’s INCLUDE approach.
Partial Indexes (Filtered Indexes)
Partial indexes apply a WHERE condition during index creation, storing only rows that match the predicate. This is powerful when queries consistently filter on the same conditions, or when you’re dealing with sparse data.
Consider a users table where 95% of accounts are active, but your queries always filter for active users:
CREATE INDEX idx_users_active ON users (email, last_login)
WHERE status = 'active';
This index only includes active users. Compare the sizes:
-- Full index
CREATE INDEX idx_users_full ON users (email, last_login);
-- Result: 1,247 MB
-- Partial index
CREATE INDEX idx_users_active ON users (email, last_login) WHERE status = 'active';
-- Result: 67 MB
The partial index is 95% smaller, which means:
- Faster index scans (less data to read)
- Better cache utilization (more index fits in memory)
- Reduced write overhead (inactive user updates don’t touch this index)
- Lower storage costs
For the index to be used, your query must include the same predicate:
-- Uses partial index
SELECT email, last_login
FROM users
WHERE status = 'active'
AND last_login > NOW() - INTERVAL '7 days';
-- Cannot use partial index (different predicate)
SELECT email, last_login
FROM users
WHERE status = 'inactive';
The query planner is smart enough to recognize when the query’s WHERE clause is compatible with the partial index condition.
Combining Both Strategies
The real magic happens when you combine covering and partial indexes. This creates laser-focused indexes for specific query patterns.
Here’s an e-commerce scenario: you frequently query recent, non-cancelled orders with customer and payment details:
CREATE INDEX idx_orders_recent_covering ON orders
(created_at DESC, customer_id)
INCLUDE (order_id, total_amount, payment_method, shipping_address_id)
WHERE
status IN ('pending', 'processing', 'completed')
AND created_at > NOW() - INTERVAL '90 days';
This index:
- Only indexes orders from the last 90 days (partial)
- Excludes cancelled/refunded orders (partial)
- Includes all columns needed for order listing queries (covering)
- Sorts by creation date for efficient pagination (ordered)
Query performance comparison for a typical order listing:
SELECT order_id, customer_id, total_amount, payment_method, created_at
FROM orders
WHERE status IN ('pending', 'processing', 'completed')
AND customer_id = 12345
AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC
LIMIT 20;
Without optimization: 234ms (seq scan, 2.1M rows examined)
With simple index on customer_id: 89ms (index scan + table lookups)
With covering + partial index: 3ms (index-only scan, 450 rows examined)
Design Considerations and Trade-offs
Every index has costs. Write operations (INSERT, UPDATE, DELETE) must maintain all indexes, and storage isn’t free. Here’s how to think about the trade-offs:
When to use covering indexes:
- Read-heavy workloads where query speed matters more than write speed
- Queries that consistently select the same column set
- High-traffic API endpoints with predictable access patterns
When to use partial indexes:
- Queries with consistent filter predicates (status flags, date ranges)
- Sparse data where most rows don’t match query conditions
- Multi-tenant systems where each tenant’s data is queried separately
When to avoid:
- High write volume with low query frequency
- Highly variable query patterns that don’t benefit from specific indexes
- Small tables that fit entirely in memory (sequential scans are fast enough)
Monitor your indexes to ensure they’re actually being used:
-- PostgreSQL index usage statistics
SELECT
schemaname,
tablename,
indexname,
idx_scan as scans,
idx_tup_read as tuples_read,
idx_tup_fetch as tuples_fetched,
pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;
Indexes with zero scans are dead weight. Drop them.
Write performance impact is real. Here’s a comparison:
-- Benchmark: 10,000 inserts
-- No indexes: 1.2s
-- 3 simple indexes: 2.1s
-- 3 covering indexes: 3.4s
-- 3 covering + partial indexes: 2.8s
Partial indexes actually help write performance by reducing the number of rows that need indexing.
Real-World Use Cases
Multi-tenant SaaS applications:
CREATE INDEX idx_events_tenant_covering ON events
(tenant_id, created_at DESC)
INCLUDE (event_type, user_id, metadata)
WHERE created_at > NOW() - INTERVAL '1 year';
This handles the common pattern of querying recent events for a specific tenant, which represents 99% of queries in most SaaS apps.
Time-series data with hot/cold partitioning:
CREATE INDEX idx_metrics_hot ON metrics
(device_id, timestamp DESC)
INCLUDE (value, unit)
WHERE timestamp > NOW() - INTERVAL '7 days';
Recent data gets a covering index. Older data either has simpler indexes or relies on table scans, since it’s queried infrequently.
API pagination with filters:
CREATE INDEX idx_products_api ON products
(category_id, price, created_at DESC)
INCLUDE (name, sku, image_url)
WHERE is_published = true AND deleted_at IS NULL;
Perfect for product listing endpoints that filter by category, sort by price or date, and need basic product details.
Best Practices and Gotchas
Common mistakes:
Don’t create redundant indexes:
-- Redundant!
CREATE INDEX idx1 ON orders (customer_id);
CREATE INDEX idx2 ON orders (customer_id, status);
-- The second index can handle queries that only filter on customer_id
Don’t make partial index conditions too specific:
-- Too specific - won't match most queries
CREATE INDEX idx_bad ON orders (customer_id)
WHERE status = 'completed' AND created_at = '2024-01-15';
-- Better - matches a query pattern
CREATE INDEX idx_good ON orders (customer_id)
WHERE status = 'completed';
Testing methodology:
- Capture real query workload using pg_stat_statements or slow query logs
- Identify the top 20% of queries by execution time × frequency
- Design indexes for those specific patterns
- Test with EXPLAIN ANALYZE on production-sized datasets
- Monitor index usage after deployment
Database-specific syntax:
SQL Server uses filtered indexes with similar syntax:
CREATE INDEX idx_orders_active ON orders (customer_id, created_at)
INCLUDE (total_amount)
WHERE status = 'active';
MySQL 8.0+ supports functional indexes but not partial indexes. Work around this with generated columns:
ALTER TABLE orders ADD COLUMN is_active BOOLEAN AS (status = 'active') STORED;
CREATE INDEX idx_orders_active ON orders (is_active, customer_id, created_at);
The bottom line: covering and partial indexes are precision tools. Use them deliberately, measure their impact, and don’t over-index. A well-designed set of 5-10 strategic indexes beats 50 generic ones every time.