DEV Community

Cover image for How to Read SQL Server Execution Plans: 7 Things That Matter
Mashrul Haque
Mashrul Haque

Posted on

How to Read SQL Server Execution Plans: 7 Things That Matter

A practical SQL Server execution plan tutorial. These seven patterns reveal 90% of performance problems.

Learn to read SQL Server execution plans fast. Focus on 7 patterns: arrow thickness, scans vs seeks, key lookups, sorts, row estimates, warnings, and why percentages lie.


TL;DR

You don't need to understand every operator to read SQL Server execution plans effectively. Focus on seven things: arrow thickness, scans vs seeks, key lookups, sorts, estimated vs actual rows, yellow warnings, and the fact that percentages lie. Master these patterns and you'll diagnose most performance problems in minutes.


Table of Contents


The Three Days I'll Never Get Back

I ignored execution plans for five years.

"I'm a developer," I told myself. "That's DBA stuff."

Then I spent three days debugging a slow report. Rewrote the query six different ways. Switched LEFT JOINs to INNER JOINs. Even filed a ticket begging infrastructure for more RAM. They said no. Of course they said no.

Finally, a senior DBA sat down, opened the execution plan, pointed at a fat arrow, and said: "You're reading 4 million rows to return 12."

Thirty seconds to spot. Another minute to fix with a missing index.

Three days of my life, gone.

Don't be me. Learn to read execution plans.


Getting Your First Execution Plan

Microsoft's execution plan documentation covers the basics well. But here's the practical version.

In SQL Server Management Studio (SSMS), you have two options:

Estimated Execution Plan (Ctrl+L)

Shows what the optimizer thinks will happen. Doesn't actually run the query. Useful for:

  • Long-running queries you don't want to wait for
  • INSERT/UPDATE/DELETE statements you'd rather not execute
  • Quick "what if" analysis

Actual Execution Plan (Ctrl+M)

Toggle this on, then run your query. Shows what actually happened:

  • Real row counts, not estimates
  • Actual execution times
  • Memory grant information
  • Warnings that only appear at runtime

Always prefer actual plans when possible. The gap between estimated and actual is where problems hide.

Reading Direction

Execution plans read right to left, bottom to top. Data flows from the rightmost operators (data sources) toward the leftmost operator (final result).

Here's what nobody tells you: for most troubleshooting, you can skip the flow analysis entirely. Just scan for visual patterns. Fat arrows. Yellow triangles. Large percentage numbers. That's where 80% of problems live.


The 7 Things That Actually Matter

1. Arrow Thickness = Data Volume

The arrows connecting operators show data flow. Thicker arrows mean more rows.

When a thin arrow suddenly becomes massive, something went wrong:

  • A join multiplied rows unexpectedly
  • A filter isn't working (or there's no filter at all)
  • An index scan is reading way more than needed

What to do: Follow the fat arrow back to its source. That operator needs a better index or filter.

2. Index Scan vs Index Seek

This is the most fundamental distinction in execution plans.

Operator What It Means When It's OK When It's Bad
Index Seek Goes directly to specific rows Almost always good Rarely bad
Index Scan Reads entire index Small tables, need all rows Large tables, need few rows
Table Scan Reads entire heap (no clustered index) Tiny tables Almost always bad
Clustered Index Scan Reads entire table via clustered index Need most columns, most rows Need few rows

A seek means SQL Server knew exactly where to look. A scan means it had to look everywhere.

What to do: If you see a scan on a large table and your query has a WHERE clause, you're missing an index or your predicate isn't SARGable. Part 1 of this series covers why non-SARGable predicates force scans.

3. Key Lookups (and How to Eliminate Them)

You'll see this pattern:

Index Seek (NonClustered) --> Key Lookup (Clustered) --> Nested Loops
Enter fullscreen mode Exit fullscreen mode

What's happening:

  1. SQL Server finds your rows using a nonclustered index (good)
  2. The index doesn't have all columns you need
  3. For each row, it goes back to the clustered index to get the rest (bad)

One key lookup is fine. A million key lookups will destroy performance.

What to do: Add the missing columns to your index using INCLUDE:

-- Before: Index only has CustomerId
CREATE INDEX IX_Orders_Customer ON Orders(CustomerId);

-- After: Index includes columns the query needs
CREATE INDEX IX_Orders_Customer ON Orders(CustomerId)
INCLUDE (OrderDate, Total, Status);
Enter fullscreen mode Exit fullscreen mode

Now the nonclustered index "covers" your query. No lookup needed.

4. Sorts = Missing Index Ordering

When you see a Sort operator, SQL Server is reordering data in memory. This requires:

  • Memory allocation (memory grant)
  • CPU time
  • Potentially spilling to disk if memory runs out

Sometimes sorts are unavoidable. But they often indicate a missing opportunity.

What to do: If you're sorting by a column that's also in your WHERE clause, consider adding it to your index in the right order:

-- Query needs orders sorted by date for a specific customer
SELECT OrderId, OrderDate, Total
FROM Orders
WHERE CustomerId = 123
ORDER BY OrderDate DESC;

-- Index that eliminates the sort
CREATE INDEX IX_Orders_CustomerDate
ON Orders(CustomerId, OrderDate DESC)
INCLUDE (Total);
Enter fullscreen mode Exit fullscreen mode

Data comes out pre-sorted. No Sort operator needed.

5. Estimated vs Actual Rows

This is the smoking gun for statistics problems.

Hover over any operator and compare:

  • Estimated Number of Rows: What the optimizer predicted
  • Actual Number of Rows: What really happened

When these differ by 10x or more, you've found a problem. I once saw estimates of 100 rows when the actual was 2.3 million. The query took 45 seconds because the optimizer picked a nested loop join when it should have used a hash join.

Estimated Actual Problem
100 100,000 Statistics are stale or missing
100,000 100 Same, but plan is over-prepared
1 1,000,000 Table variable (always estimates 1 row)

What to do:

  • Update statistics: UPDATE STATISTICS TableName WITH FULLSCAN
  • Check for implicit conversions (they cause bad estimates)
  • If using table variables with many rows, switch to temp tables

6. Yellow Triangles = Warnings

Yellow warning triangles are SQL Server telling you something went wrong. Always click them.

I spent years ignoring these because they looked intimidating. Turns out they're the most helpful part of the plan. Common warnings:

Warning What It Means Fix
Missing Index Optimizer knows a better index exists Consider creating it
No Join Predicate Cartesian product (every row x every row) Add proper ON clause
Implicit Conversion Data type mismatch killing performance Match types explicitly
Spill to TempDB Memory grant was too small Fix estimates or increase memory
Residual Predicate Filter applied after reading, not during Check SARGability

The missing index warning is especially useful. SQL Server tells you exactly what index would help and estimates the improvement percentage.

But don't blindly create every suggested index. I made this mistake early in my career and ended up with 47 indexes on one table. Writes slowed to a crawl. The suggestions are query-specific and don't consider write overhead. Use them as hints, not commands.

7. Percentages Lie

The cost percentages shown in execution plans are estimated relative costs, not actual time.

An operator showing "1%" can still be your bottleneck. Why:

  • Percentages are based on the optimizer's cost model
  • They don't account for actual wait times (network, disk, blocking)
  • A "cheap" operation executed 10 million times adds up

What to do: Don't chase the highest percentage blindly. Instead:

  • Look at actual row counts and actual execution times
  • Use SET STATISTICS TIME ON for real duration
  • Use SET STATISTICS IO ON for real I/O (this is my default now)

If STATISTICS IO shows 50,000 logical reads on an operator that claims 2% cost, trust the I/O numbers. The percentages are guesses. The I/O numbers are facts.


Real Examples: Three Broken Queries, Three Fixes

Example 1: The Missing Index

Query:

SELECT OrderId, CustomerId, OrderDate, Total
FROM Orders
WHERE Status = 'Pending' AND OrderDate > '2025-01-01';
Enter fullscreen mode Exit fullscreen mode

Execution plan shows:

  • Clustered Index Scan (100% cost)
  • Estimated rows: 5,000
  • Actual rows: 50,000
  • Fat arrow flowing through

Problem: No index on Status or OrderDate. SQL Server reads the entire table.

Fix:

CREATE INDEX IX_Orders_StatusDate
ON Orders(Status, OrderDate)
INCLUDE (CustomerId, Total);
Enter fullscreen mode Exit fullscreen mode

Result: Clustered Index Scan becomes Index Seek. Logical reads drop from 45,000 to 180.

Example 2: The Key Lookup Killer

Query:

SELECT o.OrderId, o.OrderDate, c.CustomerName, o.Total
FROM Orders o
JOIN Customers c ON o.CustomerId = c.CustomerId
WHERE o.Status = 'Shipped'
ORDER BY o.OrderDate DESC;
Enter fullscreen mode Exit fullscreen mode

Execution plan shows:

  • Index Seek on IX_Orders_Status (good!)
  • Key Lookup on Orders clustered index (50,000 executions!)
  • Nested Loops join
  • Sort operator

Problem: Index on Status finds rows, but query needs OrderDate and Total, requiring 50,000 trips back to the clustered index.

Fix:

-- Recreate index with INCLUDE columns and proper order
CREATE INDEX IX_Orders_Status
ON Orders(Status, OrderDate DESC)
INCLUDE (CustomerId, Total);
Enter fullscreen mode Exit fullscreen mode

Result: Key Lookups disappear. Sort disappears (data is pre-ordered). Logical reads drop from 150,000 to 2,500.

Example 3: The Implicit Conversion

Query:

-- @CustomerId comes from C# as NVARCHAR
DECLARE @CustomerId NVARCHAR(20) = '12345';

SELECT OrderId, OrderDate
FROM Orders
WHERE CustomerCode = @CustomerId;  -- CustomerCode is VARCHAR(20)
Enter fullscreen mode Exit fullscreen mode

Execution plan shows:

  • Index Scan instead of Seek (even with index on CustomerCode)
  • Yellow warning triangle
  • Warning text: "Type conversion in expression may affect CardinalityEstimate"

Problem: NVARCHAR has higher precedence than VARCHAR. SQL Server converts every row's CustomerCode to NVARCHAR for comparison, making the index useless.

Fix:

-- Option 1: Cast the parameter
WHERE CustomerCode = CAST(@CustomerId AS VARCHAR(20))

-- Option 2: Declare with correct type
DECLARE @CustomerId VARCHAR(20) = '12345';
Enter fullscreen mode Exit fullscreen mode

Result: Index Scan becomes Index Seek. Logical reads drop from 12,000 to 3.


The 80/20 Rule: What to Ignore

Not everything in an execution plan matters. Here's what you can skip:

Parallelism

Seeing Parallelism (Gather Streams) or Parallelism (Repartition Streams) isn't automatically bad. SQL Server is using multiple CPUs. That's usually good.

Only worry about parallelism when:

  • A simple query goes parallel (something's wrong with estimates)
  • You see CXPACKET or CXCONSUMER waits causing blocking
  • The query runs on a server that needs CPU for other work

Compute Scalar

These are calculations like Column * 1.1 or GETDATE(). They're almost always trivial cost. Ignore them unless you see billions of executions.

Small Table Scans

A table scan on a 100-row lookup table is fine. Don't create an index for it. The scan finishes in microseconds. I've seen developers add indexes to 50-row reference tables. Complete waste.

Nested Loops on Small Result Sets

Nested loops are efficient when the outer input is small. Don't let the name scare you. A nested loop with 10 outer rows hitting an indexed inner table is optimal. Hash joins and merge joins have higher startup costs.

Percentages Under 1%

If an operator shows 0.1% cost, it's not your problem. Focus on the big hitters.


Frequently Asked Questions

Where do I find execution plans for queries I didn't write?

Use Query Store (SQL Server 2016+):

-- Find plans for a specific query pattern
SELECT
    qsqt.query_sql_text,
    qsp.query_plan,
    qsrs.avg_duration / 1000000.0 AS avg_duration_seconds
FROM sys.query_store_query_text qsqt
JOIN sys.query_store_query qsq ON qsqt.query_text_id = qsq.query_text_id
JOIN sys.query_store_plan qsp ON qsq.query_id = qsp.query_id
JOIN sys.query_store_runtime_stats qsrs ON qsp.plan_id = qsrs.plan_id
WHERE qsqt.query_sql_text LIKE '%Orders%'
ORDER BY avg_duration_seconds DESC;
Enter fullscreen mode Exit fullscreen mode

Or check the plan cache for currently cached plans:

SELECT
    qp.query_plan,
    qs.execution_count,
    qs.total_elapsed_time / qs.execution_count AS avg_time_us
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
ORDER BY avg_time_us DESC;
Enter fullscreen mode Exit fullscreen mode

Can I save execution plans?

Yes. Right-click the plan in SSMS and choose "Save Execution Plan As." It saves as a .sqlplan file you can open later or share with colleagues.

For automated collection, Query Store saves all plans automatically.

How do I compare two execution plans?

SSMS 2016+ has built-in plan comparison. Right-click a plan and choose "Compare Showplan." It highlights differences between two plans.

This is invaluable for debugging regressions: compare the fast plan from last week to the slow plan from today.

What if the actual plan looks fine but the query is still slow?

The execution plan shows work done inside SQL Server. It doesn't show:

  • Network time sending results to the client
  • Blocking from other queries (locks)
  • Disk I/O waits
  • Memory pressure

Use sys.dm_exec_requests and sys.dm_os_wait_stats to see what the query is waiting on. The problem might be external to the query itself. I once spent hours optimizing a query that was fine. The real problem was network latency returning 50,000 rows to a client application that should have been paginating.

Are execution plans different between SQL Server versions?

The operators are mostly the same, but newer versions have additional features:

  • SQL Server 2016+: Live Query Statistics (watch plan execute in real-time)
  • SQL Server 2017+: Adaptive joins, interleaved execution
  • SQL Server 2019+: Scalar UDF inlining shown in plans
  • SQL Server 2022+: Parameter Sensitive Plan variants, DOP feedback

The concepts in this post apply to all versions.


Final Thoughts

Learning to read SQL Server execution plans isn't hard. They're just SQL Server showing its work. Once you know what to look for, they become the fastest way to diagnose performance problems.

Start with the seven things that matter:

  1. Fat arrows = too much data
  2. Index scans = missing or unusable index
  3. Key lookups = index missing columns
  4. Sorts = index missing order
  5. Estimated ≠ Actual = statistics problem
  6. Yellow triangles = explicit warnings
  7. Percentages lie = trust I/O stats

You don't need to understand every operator. You don't need to memorize cost formulas. Just recognize patterns.

Next up: indexes. Why column order matters, when to use INCLUDE, and how to find the indexes you're missing (and the ones you don't need).


About the Author

I'm Mashrul Haque, a Systems Architect with over 15 years of experience building enterprise applications with .NET, Blazor, ASP.NET Core, and SQL Server. I specialize in Azure cloud architecture, AI integration, and performance optimization.

When production catches fire at 2 AM, I'm the one they call.

This is Part 2 of the SQL Server Performance Series. Part 1 covers how the optimizer makes decisions.

Top comments (0)