← All SQL tips

Reading execution plans: what to check first?

-- performance tuning · Ronald de Groot

An execution plan for a sizeable query can contain dozens of operators. Where do you start? After thirty years of performance tuning, my experience is: in nine out of ten plans you'll find the culprit with the five checks below — usually within a few minutes.

Prefer the actual execution plan (or Query Store / the cached plan with runtime statistics). The estimated plan only shows what the optimizer thought would happen, not what actually did.

1. Follow the thick arrows

The thickness of an arrow between two operators represents the number of rows flowing through it. Start at the top right (that's where SQL Server starts reading) and follow the thickest arrows. Often you'll see millions of rows being read while the query only returns a few hundred: all those rows were filtered out too late. That's almost always a sign that an index on the filter column is missing, or that a predicate isn't sargable.

-- Not sargable: function wrapped around the column, index unusable
WHERE YEAR(OrderDate) = 2026

-- Sargable: the index on OrderDate can do a seek
WHERE OrderDate >= '2026-01-01' AND OrderDate < '2027-01-01'

2. A scan where you expect a seek

A Clustered Index Scan or Index Scan on a large table, while your query only needs a handful of rows, is check number two. Common causes:

Note: a scan isn't wrong by definition. For a small table, or when you genuinely need almost all rows, a scan is actually the cheapest choice.

3. Key Lookups with high row counts

A Key Lookup means: the nonclustered index produced the row, but some columns were missing, so SQL Server has to go back to the clustered index for every row. With ten rows, no problem; with a hundred thousand rows, that's a hundred thousand extra I/O operations. The fix is usually a covering index:

CREATE INDEX IX_Orders_CustomerID
ON dbo.Orders (CustomerID)
INCLUDE (OrderDate, TotalAmount);   -- carry the lookup columns in the index

I go deeper into this in my article on index tuning.

4. Estimated vs. actual rows: is the estimate right?

For each operator, compare the estimated row count with the actual one. A deviation by a factor of 10, 100 or more means the optimizer based its choices (join type, memory, order) on wrong information. The most common causes:

5. Warnings: the exclamation marks

Operators with a yellow exclamation mark always deserve a look. The most important warnings:

WarningMeaning
Spill to tempdbToo little memory granted; a sort or hash join falls back to tempdb — often the result of bad estimates (point 4).
Implicit conversionData type mismatch; can make index seeks impossible (point 2).
No join predicateCartesian product — almost always a forgotten JOIN condition.
Excessive memory grantThe query requested far more memory than it used; squeezes out other queries.

Finally: measure beyond the plan

The plan doesn't tell you everything. When tuning, also switch this on:

SET STATISTICS IO, TIME ON;

The logical reads per table are the most honest metric for comparing a before-and-after situation: they're independent of cache and server load. If the number of logical reads drops dramatically, your fix is a real improvement — whatever the cost percentages in the plan may claim.

Need help with a stubbornly slow plan? Feel free to get in touch — a second opinion on an execution plan is quickly given.