What Is a Covering Index?
A covering index holds every column a query needs, letting the database answer from the index alone without a lookup back to the table.
A covering index is an index that contains every column a specific query needs — the columns it filters or sorts on, and the columns it selects — so the database engine can answer the query by reading the index alone, without a second trip back to the underlying table. That second trip, often called a lookup or bookmark lookup, is usually the more expensive part of an index-based read, so eliminating it is one of the more reliable ways to speed up a hot query without changing its logic.
Why a normal index still needs a lookup
Understanding covering indexes starts with what a regular index does and doesn’t store. An index — commonly a B-tree — holds the indexed column’s values in sorted order, each paired with a pointer to the corresponding row in the table. A query like:
SELECT name, email FROM users WHERE id = 42;
with an index on id finds the matching entry quickly by searching the sorted structure, but the index entry only holds id and a pointer — not name or email. The engine has to follow that pointer back to the table’s actual storage (sometimes called the heap) to fetch the rest of the row. For a single row that’s cheap, but for a query returning thousands of rows, that’s thousands of extra random-access reads, one per row, on top of the index scan itself.
What makes an index “covering”
An index covers a query when every column the query touches — in the WHERE, ORDER BY, and SELECT clauses — is present in the index itself, either as a key column or as an included column some databases let you attach without making it part of the sort order. For the query above, an index defined as:
CREATE INDEX idx_users_id_covering ON users (id) INCLUDE (name, email);
lets the engine find the matching id entry and read name and email directly from that same index entry — no lookup back to the table at all. The query optimizer recognizes this and chooses what’s usually called an index-only scan, visible in an EXPLAIN plan’s absence of a heap-fetch step. How query optimizers decide between scan strategies covers the broader decision process this feeds into.
Key columns vs included columns
Most databases distinguish between columns that are part of an index’s sort order (key columns) and columns just carried along for lookups (included or non-key columns):
- Key columns determine the index’s sort order and are what the engine searches on. Adding more key columns makes the index useful for a wider range of
WHERE/ORDER BYcombinations, but also makes the index larger and more expensive to maintain on every write. - Included columns ride along in each index entry without affecting sort order. They exist purely so a query can read them without a table lookup, and they don’t help the engine search or filter — only retrieve.
Putting a column in INCLUDE rather than in the key list keeps the index smaller and faster to search when that column is only ever selected, never filtered or sorted on.
The tradeoff: covering indexes aren’t free
A covering index is still an index, and every index adds overhead to writes: an INSERT, UPDATE, or DELETE has to update every index on the affected columns, not just the table itself. A covering index that includes several extra columns means more data duplicated into the index and more work on every write that touches any of those columns. The decision to add one is a tradeoff specific to a query pattern that’s hot enough — run often enough, or over enough rows — that the read savings outweigh the write cost. This is the same tension that runs through most indexing decisions, and it’s worth remembering that a table can carry materialized views as another way to precompute expensive reads, trading storage and write cost for read speed in a different shape.
Covering indexes vs regular indexes
| Regular index | Covering index | |
|---|---|---|
| Stores | Indexed column(s) + row pointer | Indexed column(s) + all query-referenced columns |
| Table lookup needed | Yes, for non-indexed columns | No, if the index covers the query |
| Write overhead | Lower | Higher (more columns to maintain) |
| Best for | Filtering/sorting on a few columns | Specific hot queries returning known columns |
| Risk | Slower reads on wide SELECTs | Wasted space if the query set changes |
When it’s worth building one
Covering indexes pay off most clearly on queries that run very frequently, return a bounded and predictable set of columns, and currently show a lookup step dominating their execution time in EXPLAIN output. A dashboard query that runs on every page load and selects five specific columns is a strong candidate; an ad hoc reporting query that selects * and changes shape often is not, since a covering index would need to include every possible column and would need rebuilding whenever the query changes. This is a case where indexing decisions in relational systems and NoSQL systems diverge somewhat — document stores and key-value systems handle “give me the whole record from one lookup” differently by design, since the whole document is typically the retrieval unit already.
Building the right covering index also depends on understanding whether the underlying performance problem is really about reads at all — a table so large that any single-server indexing strategy stops scaling is a sign to look at partitioning or sharding instead, which solves a different bottleneck than an index ever can.
The takeaway
A covering index eliminates the table lookup a normal index still needs, by storing every column a specific query touches directly in the index itself — turning a scan-plus-lookup into an index-only scan. It’s a targeted optimization for a known, frequent query shape, not a general-purpose indexing strategy, and it comes with the same write-cost tradeoff every index does, just amplified by the extra columns it carries. Check EXPLAIN for a heap-fetch or lookup step on a hot query before reaching for one — that’s the signal a covering index is actually solving the bottleneck that’s there.
Tagged
Keep reading
The Lycoris Team · · 6 min read How to Read a Postgres EXPLAIN ANALYZE Query Plan
A step-by-step guide to running EXPLAIN ANALYZE in PostgreSQL and reading the query plan it returns — node types, costs, and where the real time went.
The Lycoris Team · · 5 min read Postgres Index Types: B-Tree vs GIN vs GiST
Postgres offers several index types beyond the default B-tree. When GIN and GiST outperform it for arrays, JSONB, full-text search, and ranges.
The Lycoris Team · · 5 min read The N+1 Query Problem and How to Fix It
The N+1 query problem turns one database request into hundreds by issuing a separate query per row. Here's how to spot it and fix it.