Using MySQL Covering Indexes to Cut Disk I/O on Read‑Heavy Tables
Learn how MySQL covering indexes let queries read data directly from the index tree, cutting disk I/O, and see the trade‑offs in size and write performance.
11 Jul 2025, 20:38 UTC

The problem: unnecessary row lookups slow down read‑heavy queries
When a query asks for columns that are not part of the index used to locate rows, MySQL must first find the matching index entries and then perform a lookup in the clustered index (the table’s data pages) to fetch the missing columns. Each extra lookup translates into random disk I/O, which becomes a bottleneck on large tables with high read traffic.
What a covering index gives you
A covering index contains all columns referenced by a query—those in the SELECT list, WHERE, ORDER BY, GROUP BY, or any other clause—so the storage engine can satisfy the request entirely from the index tree. When this happens, EXPLAIN shows the string Using index in the Extra column, indicating that no row lookup is needed.
How MySQL satisfies a query from the index alone
InnoDB’s secondary indexes are B‑Tree structures that store the indexed columns plus the primary‑key value at each leaf. If the index already holds every column the query needs, the engine can read the required values directly from the leaf nodes and skip the clustered‑index fetch.
Left‑most prefix rule
For a composite index to be usable, the query must reference a left‑most prefix of the index columns. Equality predicates on the leading columns allow the engine to navigate the tree efficiently; additional columns in the index can then be used for covering without breaking the prefix rule.
Worked example: orders table
Consider a typical schema:
CREATE TABLE orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
order_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
amount DECIMAL(10,2)
) ENGINE=InnoDB;
A frequent report retrieves the customer and date for shipped orders:
SELECT customer_id, order_date
FROM orders
WHERE status = 'shipped'
ORDER BY order_date DESC
LIMIT 100;
First, create a simple index on the filtered column:
CREATE INDEX idx_status ON orders(status);
Running EXPLAIN on the query yields (illustrative format):
+----+-------------+-----------+------------+------+---------------+-------------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+---------------+-------------+---------+-------+------+----------+-------------+
| 1 | SIMPLE | orders | NULL | ref | idx_status | idx_status | 57 | const | 500 | 100.00 | Using where |
+----+-------------+-----------+------------+------+---------------+-------------+---------+-------+------+----------+-------------+
The absence of Using index means MySQL must visit the base row for each matching status to read customer_id and order_date.
Now build a covering index that includes all columns needed by the query:
CREATE INDEX idx_status_covering
ON orders(status, order_date, customer_id);
Running EXPLAIN again may show:
+----+-------------+-----------+------------+------+---------------+---------------------------+---------+-------+------+----------+------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+---------------+---------------------------+---------+-------+------+----------+------------------------+
| 1 | SIMPLE | orders | NULL | ref | idx_status_covering | idx_status_covering | 57 | const | 500 | 100.00 | Using index; Using where |
+----+-------------+-----------+------------+------+---------------+---------------------------+---------+-------+------+----------+------------------------+
The Using index flag confirms that the engine can retrieve customer_id and order_date directly from the index leaf nodes, eliminating the costly row lookup.
Trade‑off: larger index and write impact
Adding columns to an index increases its size on disk and in memory. A wider index can lead to more page splits during INSERTs, UPDATEs, and DELETEs, potentially slowing write throughput. Moreover, because the index must stay sorted on the leading columns, the order of columns matters: placing a low‑cardinality column first may reduce the index’s ability to filter effectively, while putting a high‑cardinality column first improves selectivity but may increase the index width if many columns are added for covering.
Actionable checklist
- Identify the read‑heavy queries that run most often.
- List every column referenced in SELECT, WHERE, ORDER BY, GROUP BY, and any JOIN conditions.
- Design a composite index whose leading columns match equality predicates (the WHERE clause). Append the remaining columns needed for covering.
- Test with
EXPLAINand verify that the Extra column containsUsing index. - Measure the index size before and after using:
SELECT INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'orders'; - Monitor write latency (e.g., average INSERT time) after the index is added; if it degrades beyond an acceptable threshold, consider a narrower covering index, partitioning, or archiving old data.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.