A covering index doesn’t just help the database find rows — it can let the database avoid touching the table at all. This file explains index-only scans, why they’re dramatically faster, and how to design a composite index that covers a specific query.
EXPLAIN output to confirm whether a query is index-onlyA dashboard runs this exact query thousands of times per hour:
SELECT order_id, order_date, status
FROM orders
WHERE customer_id = 88291;
An index on customer_id alone still requires a second lookup into the
table (or clustered index) to fetch order_date and status for every
matching row. If the index instead includes all three columns, the
database can answer the entire query from the index alone — no table
access required. At high query volume, this is the difference between
acceptable and unacceptable dashboard latency.
Secondary indexes normally store just enough to locate the full row (a primary key value in InnoDB, a TID in PostgreSQL). If every column the query needs is already present in the index itself, that second lookup is unnecessary — this is what “covering” means, and the resulting plan is called an index-only scan.
SELECT *.COUNT, SUM) that only need indexed columns.INDEX (customer_id, order_date, status) <- from File 03
SELECT order_id, order_date, status FROM orders WHERE customer_id = X;
Wait — order_id isn’t in the index definition above as a value column,
but in InnoDB it IS present implicitly: every secondary index leaf
entry includes the primary key value (needed to locate the full row if
required). So this composite index already covers order_id,
order_date, and status — it satisfies the whole query from the index
alone.
-- MySQL: composite index that happens to cover the query
CREATE INDEX idx_orders_customer_date_status
ON orders (customer_id, order_date, status);
-- SQL Server: explicit INCLUDE for non-key covering columns
CREATE INDEX idx_orders_customer
ON orders (customer_id)
INCLUDE (order_date, status);
-- PostgreSQL: equivalent explicit INCLUDE (v11+)
CREATE INDEX idx_orders_customer
ON orders (customer_id)
INCLUDE (order_date, status);
INCLUDE clause — you achieve covering purely through
composite column order, which is why File 03’s leftmost prefix design
and File 05’s covering design are really the same underlying tool used
for two purposes.INCLUDE adds columns to the leaf level
without making them part of the searchable key — useful when you
want covering without affecting sort order or leftmost-prefix
behavior.Non-covering index lookup:
index seek → get primary key → SECOND lookup into table/clustered
index to fetch remaining columns (2 I/O operations)
Covering index lookup:
index seek → all needed columns already at the leaf → done
(1 I/O operation)
EXPLAIN output, Extra column:
"Using where" → normal filter, may still hit table
"Using index" → COVERING — index-only scan, no
table access required
"Using index condition" → index condition pushdown, still
touches table for final columns
SELECT * defeats covering indexes almost by definition — the wider the
SELECT list, the less likely any reasonably-sized index covers it. This
is one of the concrete performance reasons (beyond general hygiene) to
select only the columns you actually need.
Covering indexes are wider than the minimal index needed just for filtering, so they cost more disk space and more write overhead per insert/update — this is the direct trade-off against read speed, and should be a deliberate choice for specific hot queries, not a blanket strategy.
MySQL’s EXPLAIN shows Using index in the Extra column specifically
to signal an index-only scan — this is the exact string to check for
when validating that a covering index design is actually working as
intended.
Covering indexes are purely a physical optimization; there is no standard SQL concept for them — the standard only defines what a query returns, and covering never changes that, only how cheaply it’s produced.
INCLUDE syntax — achieve covering via composite column ordering.EXPLAIN ... \G and check Extra: Using index.INCLUDE (v11+) lets you add covering columns without affecting the
key’s sort/search behavior — useful when the covering columns
shouldn’t influence leftmost-prefix matching.EXPLAIN and look for Index Only Scan.INCLUDE is the idiomatic way to build a covering index; SQL Server’s
execution plan explicitly labels the operator as an Index Seek
with no corresponding Key Lookup when covering succeeds.INDEX (FAST FULL SCAN) or the absence of
a TABLE ACCESS BY INDEX ROWID step in the execution plan.EXPLAIN after query changes, not just after index changes.EXPLAIN whenever the query’s SELECT
list changes.SELECT * on a table with a carefully designed covering index,
which defeats the design.Extra: Using index and assuming a plan is index-only
because an index was used at all.SELECT * tend to prevent covering-index optimization?EXPLAIN, whether a query is actually
achieving an index-only scan in MySQL? In PostgreSQL?A covering index contains every column a query needs, letting the engine
answer the query from the index alone — an index-only scan — without
touching the underlying table. This is one of the most impactful
optimizations available for narrow, high-frequency queries, at the cost
of additional index storage and write overhead. EXPLAIN’s Using
index (MySQL) or Index Only Scan (PostgreSQL) confirms whether it’s
actually happening.
See 12_PRACTICE_PROBLEMS.md, Advanced section, Problems 1–2.