LyraLearn AI Learning Platform
Exams
← Module 3 Β· Index Design That Actually Helps

Covering Indexes and Killing Lookups

A covering index contains every column a query touches β€” in the SELECT list, the WHERE clause, the JOIN conditions, the GROUP BY and the ORDER BY. When one exists, SQL Server answers the query entirely from the index and never visits the table. The plan shows a clean Index Seek with no Key Lookup attached.

This is the most reliably effective index change you can make to a slow report, because report queries typically filter narrowly and select many columns, which is exactly the shape that produces per-row lookups.

Building one from the query

Take the query apart and assign each column a role:

SELECT  o.OrderDate,          -- output + order by
        o.CustomerId,         -- output + join
        SUM(o.Total) AS Total -- output, aggregated
FROM    dbo.Orders AS o                  -- placeholder table
WHERE   o.RegionId = @RegionId           -- equality  -> key
  AND   o.StatusId = @StatusId           -- equality  -> key
  AND   o.OrderDate >= @From             -- range     -> key (after equalities)
  AND   o.OrderDate <  @To
GROUP BY o.OrderDate, o.CustomerId
ORDER BY o.OrderDate;
CREATE NONCLUSTERED INDEX IX_Orders_Region_Status_Date_Customer
    ON dbo.Orders (RegionId, StatusId, OrderDate, CustomerId)
    INCLUDE (Total);

Now verify with an actual plan: Index Seek, no Key Lookup, ideally no Sort. That is a covered query.

The trade-off is real

Covering is not free. Every INCLUDE column is another copy of that data on disk, in the buffer pool, and in the write path. Two rules keep it sane:

The practical middle ground for reports: cover the columns the report actually renders, and push back on the ones nobody looks at. Which is Module 6's argument, arriving early.

When covering is the wrong answer

If the query returns most of the table anyway, a covering index only means you scan a narrower structure. That is still a win β€” a narrower index means fewer pages β€” but it is a modest one, and the real fix is returning fewer rows.

If the query has no selective predicate at all, covering does not create one. The plan will scan the covering index instead of the clustered index. Slightly faster, same shape.

Finding queries that would benefit

Lookups leave a trace in the index usage DMV: user_lookups counts clustered-index lookups driven by a nonclustered index seek.

SELECT  OBJECT_SCHEMA_NAME(s.object_id) AS schema_name,
        OBJECT_NAME(s.object_id)        AS table_name,
        i.name                          AS index_name,
        s.user_seeks,
        s.user_scans,
        s.user_lookups,
        s.user_updates
FROM sys.dm_db_index_usage_stats AS s
JOIN sys.indexes AS i
     ON i.object_id = s.object_id AND i.index_id = s.index_id
WHERE s.database_id = DB_ID()
  AND s.user_lookups > 0
ORDER BY s.user_lookups DESC;

High user_lookups on the clustered index of a table your report reads is a direct pointer at the work covering would eliminate. Pair it with the plan-cache search from Module 2 Lesson 3 to find the specific queries.

Verifying the change honestly

SET STATISTICS IO ON;

-- run the report query, note "logical reads" per table
SELECT TOP (1) 1;    -- placeholder: your report query here

SET STATISTICS IO OFF;

Before and after logical reads on the base table is the cleanest evidence. If lookups were the problem, that number typically falls by one to two orders of magnitude, and it falls consistently regardless of server load β€” unlike duration.

Rule of thumb

A covering index has every column the query touches, so the plan is a seek with no key lookup. That is usually the biggest single win on a report, because reports filter narrowly and select widely. Size it against the SELECT list, though: do not INCLUDE thirty columns to cover a SELECT *; at that point it is better to trim what the report actually needs.

🧠 Quiz yourself on this lesson →

Ask the AI Tutor

Grounded in the course lessons β€” it cites its sources and says when it doesn't know.