Stop Chasing Every Seek: When a Table Scan Is the Right Plan

Stop chasing every seek, because a table scan is the right plan when your query needs most of the table. Chasing every seek can make a query slower, not faster. Let me prove it with a small table and the reads behind each plan.

A broad rug beater beside a hanging rug and a much smaller soft brush

The scan that looked like a problem

A junior DBA opens an execution plan and sees Clustered Index Scan. “That is the problem,” they say. “Let’s force an index seek.” It sounds sensible. Seeks are fast and scans are slow, right?

Not when the query wants nearly every row. The index can find the matching keys quickly, but it does not hold the Payload column. For every key, SQL Server then jumps to the clustered index to fetch it. That jump is called a key lookup, and it is not free.

Build a table where most rows match

The table has 5,000 rows. Category 1 has 4,500 of them. An index on Category exists, but it does not include Payload.

SET NOCOUNT ON;
DROP TABLE IF EXISTS #ScanDemo;
CREATE TABLE #ScanDemo (Id int PRIMARY KEY CLUSTERED, Category int NOT NULL, Payload char(200));
INSERT #ScanDemo
SELECT value, CASE WHEN value <= 4500 THEN 1 ELSE 2 END, 'sample'
FROM GENERATE_SERIES(1, 5000);
CREATE INDEX IX_ScanDemo_Category ON #ScanDemo (Category);

Compare the three plans

Press Ctrl+M in SSMS to include the actual execution plan, then run this block. The first query forces the nonclustered route. The second forces the clustered scan. The third lets the optimizer decide. The hints are for experiments only. Do not ship them.

SELECT Id, Payload FROM #ScanDemo
WITH (INDEX(IX_ScanDemo_Category), FORCESEEK) WHERE Category = 1;

SELECT Id, Payload FROM #ScanDemo
WITH (INDEX(1), FORCESCAN) WHERE Category = 1;

SELECT Id, Payload FROM #ScanDemo WHERE Category = 1;
Forced seek plan showing Index Seek, Key Lookup and Nested Loops
The forced seek plan: an Index Seek, then a Key Lookup for each row.
Forced Clustered Index Scan graph and properties with 5,000 rows read and 4,500 returned
The forced scan reads 5,000 rows and returns 4,500, in one execution.

All three queries return the same 4,500 rows. In the seek plan, the Key Lookup carries 97 percent of the cost. In the scan plan, one operator reads 5,000 rows and keeps 4,500. That is a little wasteful, but it happens in one pass.

Count the reads

A plan picture hides the size of the work, so count pages instead. This block assigns the rows to variables, so no grid fills your screen, and prints logical reads for each query.

DECLARE @id int, @payload char(200);
SET STATISTICS IO ON;

SELECT @id = Id, @payload = Payload FROM #ScanDemo
WITH (INDEX(IX_ScanDemo_Category), FORCESEEK) WHERE Category = 1;

SELECT @id = Id, @payload = Payload FROM #ScanDemo
WITH (INDEX(1), FORCESCAN) WHERE Category = 1;

SELECT @id = Id, @payload = Payload FROM #ScanDemo WHERE Category = 1;

SET STATISTICS IO OFF;

The forced seek takes 9300 logical reads. The forced scan takes 137. The optimizer’s own choice also takes 137, so it picked the scan by itself. The seek is about 68 times more work, all of it spent on lookups.

Even a small slice can lose

You might think the seek wins once fewer rows match. Category 2 holds only 500 rows, a tenth of the table. Let me test that.

DECLARE @id int, @payload char(200);
SET STATISTICS IO ON;

SELECT @id = Id, @payload = Payload FROM #ScanDemo
WITH (INDEX(IX_ScanDemo_Category), FORCESEEK) WHERE Category = 2;

SELECT @id = Id, @payload = Payload FROM #ScanDemo
WITH (INDEX(1), FORCESCAN) WHERE Category = 2;

SET STATISTICS IO OFF;

The forced seek still costs 1044 reads against 137 for the scan. Ten percent of the table is already enough to make lookups lose. So when does the seek win? When the query is truly selective. Add three rare rows and ask for them with no hints.

INSERT #ScanDemo (Id, Category, Payload)
VALUES (5001, 3, 'rare'), (5002, 3, 'rare'), (5003, 3, 'rare');

DECLARE @id int, @payload char(200);
SET STATISTICS IO ON;

SELECT @id = Id, @payload = Payload FROM #ScanDemo WHERE Category = 3;

SELECT @id = Id, @payload = Payload FROM #ScanDemo
WITH (INDEX(1), FORCESCAN) WHERE Category = 3;

SET STATISTICS IO OFF;

Now the optimizer’s choice reads just 8 pages, and the forced scan still reads 137. Same table, same index, opposite answer. The best access path depends on how many rows you want, not on how the operator sounds.

How many rows do you need?

Clean up and what to do instead

DROP TABLE IF EXISTS #ScanDemo;

If a scan really hurts, look at the reads first. A covering index that includes Payload can avoid the lookups, but it costs storage and slows writes. Parameter values, row width and estimates also change the plan. Diagnose the whole request before you erase a scan.

Compare the total work before you change the access path.

A scan is not a failure, it is the right access path when you need most rows.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Performance, SQL Scripts, SQL Server
Previous Post
A Shared SEQUENCE for Invoices and Credit Notes in One Range
Next Post
SQL SERVER – Denali – DMV Enhancement – sys.dm_exec_query_stats – New Columns

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.