Skip to main content
SQL Server · lesson 2 of 7
SQL Server intermediate Lesson 2 of 7

Also part of: Databases

Clustered vs Nonclustered: When an Index Makes a Query Slower

Forcing SQL Server to use a nonclustered index costs 217 logical reads -- worse than the 40-read scan it was supposed to beat -- because every matching row needs a separate key lookup. INCLUDE fixes it at 4 reads.

By SkillByExample · Published

sqlserver tsql indexing covering-index key-lookup

An index existing doesn’t mean using it is faster — sometimes it’s real, measured, and noticeably worse.

A nonclustered index, forced

CREATE INDEX ix_books_author ON books(author_id);
SET STATISTICS IO ON;
SELECT title, price FROM books WITH (INDEX(ix_books_author)) WHERE author_id = 25;
(100 rows affected)
Table 'books'. Scan count 1, logical reads 217, physical reads 0, read-ahead reads 1, ...

217 logical reads — worse than a plain scan of the whole table (40, from the previous lesson). WITH (INDEX(...)) forces SQL Server to use this specific index; without the hint, the optimizer actually declines to use it for this exact query, and it’s about to become clear why.

Why: a key lookup per row

ix_books_author only stores author_id and a pointer back to the clustered index — it doesn’t have title or price. For each of the 100 matching rows, SQL Server has to do a separate key lookup back into the clustered index to fetch those two columns. A hundred small lookups cost more total page reads than one sequential scan of all 40 pages — which is exactly why the optimizer, left alone, chose the scan:

SET STATISTICS IO ON;
SELECT title, price FROM books WHERE author_id = 25;
(100 rows affected)
Table 'books'. Scan count 1, logical reads 40, physical reads 0, ...

No hint, 40 reads — the optimizer already knew the plain scan beats the lookup-heavy index plan for this query, without being told.

The fix: a covering index with INCLUDE

DROP INDEX ix_books_author ON books;
CREATE INDEX ix_books_author_covering ON books(author_id) INCLUDE (title, price);
SET STATISTICS IO ON;
SELECT title, price FROM books WHERE author_id = 25;
(100 rows affected)
Table 'books'. Scan count 1, logical reads 4, physical reads 0, ...

4 logical reads — better than both the scan (40) and the lookup-heavy index (217). INCLUDE (title, price) stores those two columns directly in the nonclustered index’s leaf pages, without making them part of the seek key. The index now covers the query entirely — author_id to find the rows, title and price already sitting right there — so no key lookup back to the clustered index is needed at all.

Practice

1. Query a column the covering index doesn't include and confirm the scan returns.
SET STATISTICS IO ON;
SELECT title, published_year FROM books WHERE author_id = 25;
Table 'books'. Scan count 1, logical reads 40, physical reads 0, ...

published_year isn’t in ix_books_author_covering’s key or its INCLUDE list, so this query can’t be satisfied by the index alone — SQL Server falls back to scanning the table rather than doing a key lookup, the same decision it made before any index existed.

2. Extend the covering index to include the newly-needed column, and confirm it's used again.
DROP INDEX ix_books_author_covering ON books;
CREATE INDEX ix_books_author_covering ON books(author_id) INCLUDE (title, price, published_year);
SET STATISTICS IO ON;
SELECT title, published_year FROM books WHERE author_id = 25;
Table 'books'. Scan count 1, logical reads 4, physical reads 0, ...

Back to 4 reads — the index now covers this query too. Extending INCLUDE is how a covering index keeps up with a query’s actual column list as it evolves.

3. Confirm sys.dm_db_index_usage_stats can show whether an index is actually being used in practice.
SELECT i.name, s.user_seeks, s.user_scans, s.user_lookups
FROM sys.dm_db_index_usage_stats s
JOIN sys.indexes i ON s.object_id = i.object_id AND s.index_id = i.index_id
WHERE s.object_id = OBJECT_ID('books');
name                                       user_seeks  user_scans  user_lookups
------------------------------------------ ----------- ----------- ------------
PK__books__490D1AE1E186C24D                      32000           8            3
ix_books_author_covering                             1           0            0

ix_books_author_covering shows user_seeks: 1 (this section’s own query), user_lookups: 0 — confirming it was seeked into directly with no key lookup needed. The clustered index’s own huge seek count is everything else run against books all session; checking this view on a real system is how you’d find out whether an index anyone bothered to create is actually being used at all, versus sitting there unused paying only its write-maintenance cost.

Next: window functions — running totals and rankings without a self-join.

Frequently Asked Questions

What's the actual difference between a clustered and nonclustered index?
A clustered index determines the physical storage order of the table's rows — there can be only one per table, and it's what a PRIMARY KEY creates by default. A nonclustered index is a separate structure that stores only its key columns plus a pointer back to the clustered key, so a table can have many of them, each optimized for a different query pattern.
Why did forcing the nonclustered index make the query slower, not faster?
The nonclustered index only stores author_id and a pointer to the clustered key — it doesn't have title or price. For every one of the 100 matching rows, SQL Server had to do a separate 'key lookup' back into the clustered index to fetch those columns, and 100 small lookups cost more total page reads (217) than one sequential scan of the whole table (40).
What does INCLUDE actually do in CREATE INDEX ... INCLUDE (...)?
It adds extra columns to the nonclustered index's leaf pages without making them part of the index's sort key — they're not used for seeking or ordering, just stored alongside so a query needing them doesn't have to look elsewhere. An index with every column a query needs, key or included, is called a covering index, because it can satisfy the query on its own.
Why didn't the query optimizer just use the nonclustered index automatically?
SQL Server's optimizer estimates the cost of every available plan and picks the cheapest one — it correctly predicted that 100 key lookups would cost more than one scan, and used the scan by default, exactly matching what this lesson measured. The WITH (INDEX(...)) hint is what forced the worse plan, specifically to make the cost difference observable.
Is it always worth adding INCLUDE columns to every index?
No — every included column makes the index's leaf pages larger, which means more pages to read for a full index scan and more storage and write overhead to maintain. INCLUDE is worth it specifically when a query's SELECT list needs columns the index's key doesn't already cover and the query runs often enough that eliminating key lookups is worth the extra index size.