Definition
- Covering index – An index that contains every column a query needs: the columns it filters on, the columns it sorts by, and the columns it returns.
- Index-only scan – The query is answered entirely from the index without ever touching the table rows, eliminating bookmark lookups and heap fetches.
- INCLUDE clause – Attaches non-key payload columns to the index leaf pages without widening the sort key, keeping the tree shallow while storing extra return columns.
- Bookmark lookup – The extra I/O a non-covering non-clustered index incurs per matching row to fetch the remaining columns from the table; covering eliminates this.
- Trade-off – Covering indexes are wider and consume more storage, so you cover only the hottest queries deliberately.
Normally the National Bank of India clerk uses the index to find the page number, then walks to the shelf to open the actual folder and read the details – two trips. A covering index is like an index card that already prints everything you asked for right on the card itself: account number, date, and amount, all there. The clerk reads the card and never walks to the shelf at all. If your question only needs what is printed on the card, you get the answer in one quick glance with no second trip to the vault.
Understanding the Concepts
What is a covering index?
A covering index contains every column a particular query touches. When all the columns the query needs for filtering, sorting, and returning are present in the index, the engine never needs to visit the table rows at all.
-- National Bank of India: mini-statement shows date and amount
-- Query needs: account_id (filter), created_at (sort), amount (return)
-- Non-covering index: only has account_id and created_at
CREATE INDEX ix_txn_acct_date
ON transactions(account_id, created_at);
-- The query also needs 'amount', which is not in the index
SELECT amount, created_at
FROM transactions
WHERE account_id = 'SAV2001'
ORDER BY created_at DESC
LIMIT 10;
-- Plan: Index Scan + Heap Fetches (must read the table for 'amount')
How does a non-covering index cause bookmark lookups?
A non-covering non-clustered index leaf stores only the key columns plus a row pointer. To get any column not in the index, the engine follows the pointer to the table. This is a bookmark lookup, and for queries returning many rows it dominates the cost.
-- Without covering: each matching row needs a heap fetch
EXPLAIN ANALYZE
SELECT amount, created_at
FROM transactions
WHERE account_id = 'SAV2001'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC;
Index Scan using ix_txn_acct_date on transactions
Index Cond: (account_id = 'SAV2001'
AND created_at >= '2026-01-01')
Heap Fetches: 3500 <-- 3500 random I/O operations
Execution Time: 45 ms
How do you create a covering index?
There are two ways to cover a query.
Method 1: Add columns to the key
-- Add amount as a third key column
CREATE INDEX ix_txn_cover_key
ON transactions(account_id, created_at, amount);
-- Now all three columns are in the index
EXPLAIN ANALYZE
SELECT amount, created_at
FROM transactions
WHERE account_id = 'SAV2001'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC;
Index Only Scan using ix_txn_cover_key on transactions
Index Cond: (account_id = 'SAV2001'
AND created_at >= '2026-01-01')
Heap Fetches: 0 <-- zero! No table access needed
Execution Time: 8 ms (5x faster)
Method 2: Use the INCLUDE clause (preferred)
-- INCLUDE attaches amount as a non-key payload column
CREATE INDEX ix_txn_cover_include
ON transactions(account_id, created_at)
INCLUDE (amount);
-- The key stays narrow (account_id, created_at)
-- but amount is available in the leaf for retrieval
EXPLAIN ANALYZE
SELECT amount, created_at
FROM transactions
WHERE account_id = 'SAV2001'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC;
Index Only Scan using ix_txn_cover_include on transactions
Index Cond: (account_id = 'SAV2001'
AND created_at >= '2026-01-01')
Heap Fetches: 0
Execution Time: 8 ms
What is the difference between key columns and INCLUDE columns?
| Feature | Key Column | INCLUDE Column |
|---|---|---|
| Sorting | Participates in B-tree sort | Not sorted |
| Filtering | Can be used in WHERE | Cannot be used in WHERE |
| Ordering | Can satisfy ORDER BY | Cannot satisfy ORDER BY |
| Purpose | Seek and sort | Avoid heap fetch only |
| Tree depth impact | Wider key = deeper tree | No impact on tree depth |
-- INCLUDE columns do NOT help filtering or ordering
-- They ONLY help avoid the heap fetch
-- This CANNOT use the INCLUDE column for filtering:
SELECT * FROM transactions
WHERE amount > 50000.00; -- amount is in INCLUDE, not the key
-- Plan: Seq Scan (amount is not in the B-tree key)
When should you use a covering index?
Cover only the hottest, most frequent queries. Every covering index is wider, consumes more storage, and slightly slows writes.
-- National Bank of India: top 3 queries by frequency
-- 1. Mini-statement (account + date + amount) - 5M queries/day
-- 2. Balance check (account + balance) - 3M queries/day
-- 3. Branch report (branch + status + count) - 10K queries/day
-- Cover #1: mini-statement
CREATE INDEX ix_txn_cover_statement
ON transactions(account_id, created_at)
INCLUDE (amount, txn_type);
-- Cover #2: balance check is already covered by the clustered PK
-- (all columns are in the clustered leaf)
-- Do NOT cover #3: too infrequent to justify the extra index width
// com.bank.service.MiniStatementService
package com.bank.service;
import java.math.BigDecimal;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class MiniStatementService {
/**
* Uses the covering index ix_txn_cover_statement.
* All needed columns (account_id, created_at, amount, txn_type)
* are in the index, so the query produces an index-only scan.
*/
public List<String> getMiniStatement(
Connection conn, String accountId) throws SQLException {
String sql = """
SELECT amount, txn_type, created_at
FROM transactions
WHERE account_id = ?
ORDER BY created_at DESC
LIMIT 10
""";
List<String> rows = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, accountId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
BigDecimal amt = rs.getBigDecimal("amount");
String type = rs.getString("txn_type");
Timestamp ts = rs.getTimestamp("created_at");
rows.add(type + " | " + amt.toPlainString()
+ " | " + ts);
}
}
}
return rows;
}
}
What is the PostgreSQL visibility map detail?
In PostgreSQL, an index-only scan still consults the visibility map to confirm each row is visible to the current transaction. If a page is not marked all-visible (because it was recently modified and not yet vacuumed), the engine falls back to a heap fetch for that page.
-- Check heap fetches in the plan
EXPLAIN (ANALYZE, BUFFERS)
SELECT amount, created_at
FROM transactions
WHERE account_id = 'SAV2001'
ORDER BY created_at DESC
LIMIT 10;
-- If Heap Fetches > 0, the table needs vacuuming
VACUUM transactions;
-- After vacuum, Heap Fetches should drop to 0
What are the trade-offs of covering indexes?
| Benefit | Cost |
|---|---|
| Zero heap fetches | Wider index = more disk space |
| Better cache hit rate | More data to write on INSERT/UPDATE |
| Lower latency | INCLUDE columns not usable for filtering |
| Fewer random I/O | Must be maintained for each query pattern |
What should a senior engineer know?
- In InnoDB, the clustered primary key already covers every column. A covering index is needed only for secondary (non-clustered) index paths.
- SQL Server calls the plan "Index Seek (Covering)" when all columns are in the index.
- A covering index on a wide table with many queries is not practical; cover only the critical hot paths.
- Monitor heap fetches in
EXPLAIN ANALYZEand vacuum frequency to keep index-only scans truly heap-free.
What are common interview follow-up questions?
- What is the difference between adding columns to the key versus using INCLUDE?
- When does a covering index NOT produce an index-only scan in PostgreSQL?
- How does a covering index interact with the left-prefix rule?
- Why is a separate covering index unnecessary when the primary key is clustered?
Visual Overview
┌─────────────────────────────────────────────────┐ │ Covering Index: Index-Only Scan │ ├─────────────────────────────────────────────────┤ │ │ │ Non-covering (bookmark lookup per row): │ │ ┌──────────────────┐ ┌───────────────┐ │ │ │ Index Leaf │──►│ Table Row │ │ │ │ [acct_id | date] │ │ [acct | date │ │ │ │ │ │ | amount] │ │ │ └──────────────────┘ └───────────────┘ │ │ Per row: index seek + heap fetch = 2 I/Os │ │ │ │ Covering (index-only scan): │ │ ┌──────────────────────────────┐ │ │ │ Index Leaf │ │ │ │ [acct_id | date | amount] │ │ │ │ (all columns present) │ │ │ └──────────────────────────────┘ │ │ Per row: index seek only = 1 I/O │ │ Table is NEVER touched │ │ │ │ INCLUDE version: │ │ ┌──────────────────────────────┐ │ │ │ Key: [acct_id | date] │ │ │ │ Payload: [amount] (INCLUDE) │ │ │ │ Key stays narrow, tree shallow│ │ │ └──────────────────────────────┘ │ └─────────────────────────────────────────────────┘
Key Takeaway
A covering index is an index that contains every column a particular query needs – the columns it filters on, the columns it sorts by, and the columns it returns – so the query can be answered entirely from the index without ever reading the table rows. That produces an index-only scan. The win is that a normal non-clustered index only stores the key plus a pointer, so any returned column not in the index forces a bookmark lookup or heap fetch per matching row, and for queries that return many rows those random lookups dominate the cost. A covering index eliminates them.
There are two ways to cover a query. You can widen the index key with the extra columns, but that bloats the key and slows seeks if those columns are never filtered or sorted. The cleaner way is the INCLUDE clause, which attaches the returned columns as non-key payload in the leaf: the key stays narrow so seeks stay fast and the tree stays shallow, while the extra columns are still available for retrieval.
At the National Bank of India the mini-statement widget showed just the date and amount for an account, and it ran millions of times a day doing a heap fetch per row to get the amount. Rajesh created a covering index keyed on account id and created-at, with the amount in an INCLUDE clause, and the plan turned into an index-only scan with zero heap fetches. Latency dropped sharply because the index pages are much smaller than the full rows, so they cached far better.
The nuance Pankaj always mentions is the PostgreSQL visibility-map detail: an index-only scan still checks whether each row is visible to the current transaction, and if a page is not marked all-visible it falls back to a heap fetch, so he keeps the table vacuumed to preserve a truly heap-free scan. He also stresses that covering indexes are wider and slightly slow writes, so the team covers only the hottest queries deliberately, and confirms with EXPLAIN ANALYZE that the plan really says Index Only Scan with Heap Fetches of zero.
Remember one sentence: a covering index holds all the columns a query needs, so the answer comes straight from the index and the table is never read.
---