Covering Indexes in MySQL: When the Index Is the Whole Answer
A covering index lets InnoDB answer a query entirely from the index tree, skipping per-row lookups. Here's how to design one, verify it with EXPLAIN, and weigh the write cost.
03 Sept 2026, 19:05 UTC

Your query filters on one indexed column, the index gets used, and yet the query is still slow. The usual culprit: MySQL found the matching index entries quickly, then spent most of its time doing a second lookup per row — walking from the secondary index back to the clustered primary key to fetch the rest of the row. A covering index eliminates that second trip entirely.
The takeaway: if an index contains every column a query touches, InnoDB can answer the query from the index tree alone. Done well, this turns random I/O into a sequential index scan and can cut query time dramatically. Done carelessly, it just makes writes slower.
Why a row lookup happens at all
In InnoDB, the table itself is stored as a B+tree organized by the primary key — the "clustered index." Every secondary index stores the indexed columns plus the primary key value at each leaf. So when a query uses a secondary index on status but also needs total and customer_id, InnoDB must take each primary key it finds and look up the full row in the clustered index. That's one extra tree descent per matching row.
A covering index sidesteps this by including all the columns the query references — in the SELECT list, the WHERE clause, and the ORDER BY. InnoDB never leaves the secondary index.
A worked example
Say you run this constantly against an orders table with millions of rows:
SELECT order_id, status, created_at
FROM orders
WHERE status = 'shipped'
ORDER BY created_at DESC
LIMIT 50;An index on (status) alone helps find shipped rows, but every match still requires a clustered-index lookup to read created_at, and then a filesort to order them. Instead, build the index to match the query's shape:
ALTER TABLE orders
ADD INDEX idx_status_created (status, created_at);Run this in a client connected as a user with ALTER privilege on the table — ideally on a staging replica first, since on large tables ALTER TABLE ... ADD INDEX can take a long time and (depending on version and settings) may hold locks. MySQL 8.0 supports instant/inplace index additions in many cases, but verify with SHOW PROCESSLIST and test on a copy.
Because order_id is the primary key, it's implicitly present in the secondary index leaf — so this index covers all three selected columns. Equality on status plus ordering on created_at also means the rows come out of the index already sorted, so no filesort is needed.
Verifying it actually worked
Don't trust the index — check the plan:
EXPLAIN SELECT order_id, status, created_at
FROM orders WHERE status='shipped' ORDER BY created_at DESC LIMIT 50;What you want to see:
keyshowsidx_status_createdExtracontainsUsing index— this is the covering-index signal, meaning no row lookupsExtradoes not containUsing filesortrows(the estimate) drops substantially compared to the pre-index plan
If you see Using where; Using index, that's still fine — it means filtering happened inside the index. If Using index is absent, some column in your query isn't in the index, and InnoDB is still visiting the clustered index.
Column order is not a detail
Composite indexes obey the leftmost-prefix rule: MySQL can use (status, created_at) for a query filtering on status alone, but not for a query filtering on created_at alone. Put equality columns first, range/sort columns after. In the example, reversing the order to (created_at, status) would break both the equality lookup and the sort optimization.
The trade-off you pay on every write
Every index is a data structure that must be maintained. Each INSERT, DELETE, or UPDATE touching an indexed column writes to every affected index tree. Covering indexes tend to be wider — more columns, more bytes per entry — so they amplify this cost and consume more buffer pool space, potentially evicting pages other queries need.
Practical guidance:
- Cover your hottest, most selective read queries — not every query.
- Before adding an index in production, measure write throughput on a replica with a similar workload, before and after.
- Audit for redundancy: an index on
(status, created_at)makes a standalone(status)index redundant — drop it to reclaim write performance.
One version note: index condition pushdown and derived-table optimizations improved significantly in MySQL 8.0; on 5.7 and earlier, some queries won't exploit covering indexes as fully. Check EXPLAIN on your actual version rather than assuming.
What to do this week
Pick your slowest frequent query, run EXPLAIN, and look for Using where without Using index, or a Using filesort. Ask: which columns does this query actually need? Build one composite index in the right column order, verify the plan shows Using index, and benchmark writes before shipping it. One well-placed covering index beats five speculative ones.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.