SQL-Engineering-Handbook

05 — Covering Indexes

Introduction

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.

Learning Objectives

Business Motivation

A 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.

Why This Exists

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.

Production Use Cases

Architecture Discussion

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.

Syntax

-- 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);

Syntax Breakdown

Visual Explanation

Covering index vs. non-covering lookup

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)

ASCII Diagram

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

Execution Flow

  1. Optimizer checks whether every column referenced in the SELECT list, WHERE clause, and any JOIN/ORDER BY is present in a single candidate index.
  2. If yes, and that index also serves the WHERE clause efficiently, the optimizer marks the plan as index-only and skips the base table entirely.
  3. If any referenced column is missing from the index, the engine falls back to a normal index-then-table-lookup plan.

Engineering Notes

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.

Performance Notes

Storage Considerations

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.

Optimizer Notes

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.

ANSI SQL Notes

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.

MySQL Notes

PostgreSQL Notes

SQL Server Notes

Oracle Notes

Edge Cases

Best Practices

Anti-patterns

Common Mistakes

Interview Questions

  1. What is the difference between an index being used and a query being covered?
  2. Why does SELECT * tend to prevent covering-index optimization?
  3. How would you confirm, using EXPLAIN, whether a query is actually achieving an index-only scan in MySQL? In PostgreSQL?
  4. What’s the storage trade-off of designing a covering index?

Summary

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.

Practice

See 12_PRACTICE_PROBLEMS.md, Advanced section, Problems 1–2.

Further Reading

See resources/documentation.md.