Skip to main content

Execution Plan

Editorial

Introduction

An execution plan is the blueprint SQL Server creates for running your query. It shows exactly which indexes are used, how tables are joined, and where the most expensive operations happen. Understanding execution plans is essential for performance tuning.

In this tutorial, you will learn:

  • What an execution plan is
  • The difference between estimated and actual plans
  • How to read the most common operators
  • How to identify performance bottlenecks
  • How the query optimizer makes decisions

Estimated vs Actual Execution Plans

SQL Server provides two types of execution plans:

Generated visual representation from text diagram for Estimated vs Actual Execution Plans.
-- Get estimated execution plan (does NOT execute)
SET SHOWPLAN_XML ON;
GO
SELECT * FROM orders WHERE order_status = 'delivered';
GO
SET SHOWPLAN_XML OFF;
GO

-- Get actual execution plan (DOES execute)
SET STATISTICS XML ON;
GO
SELECT * FROM orders WHERE order_status = 'delivered';
GO
SET STATISTICS XML OFF;
GO

-- Get text-based estimated plan
SET SHOWPLAN_TEXT ON;
GO
SELECT * FROM orders WHERE order_status = 'delivered';
GO
SET SHOWPLAN_TEXT OFF;
GO

Tip: In SQL Server Management Studio, you can also press Ctrl+L for the estimated plan or click Include Actual Execution Plan before running your query.

Common Operators

Execution plans are made up of operators. Here are the most common ones you will see:

Data Access Operators

OperatorDescriptionPerformance
Clustered Index SeekNavigates B-tree to find specific rows✅ Best — O(log n)
Clustered Index ScanReads entire clustered index⚠️ Expensive on large tables
NonClustered Index SeekNavigates NC index B-tree✅ Excellent
NonClustered Index ScanReads entire NC index⚠️ Depends on index width
Table ScanReads entire heap (no clustered index)❌ Usually bad
Key LookupFollows pointer from NC index to clustered index⚠️ Expensive in bulk
RID LookupFollows pointer from NC index to heap⚠️ Expensive in bulk

Join Operators

OperatorBest ForHow It Works
Nested LoopsSmall outer + indexed innerFor each outer row, seek inner
Hash MatchLarge unsorted inputsBuilds hash table, probes it
Merge JoinLarge pre-sorted inputsWalks both inputs in order

Other Common Operators

OperatorDescription
SortSorts data (expensive if not already sorted by index)
FilterApplies WHERE conditions after retrieval
Compute ScalarCalculates expressions
Stream AggregateGroups pre-sorted data
Hash AggregateGroups unsorted data using hash table
ParallelismDistributes/gathers work across threads

Reading the Plan

Execution plans are read right to left, bottom to top. Data flows from the leaf operators (right side) toward the final SELECT (left side).

Execution plan tree showing common operators: Table Scan, Index Seek, Nested Loops Join, and Sort with cost percentages

Using SET STATISTICS for Performance Data

Beyond the graphical plan, you can get detailed I/O and timing information using SET STATISTICS.

-- Show logical and physical reads per table
SET STATISTICS IO ON;

-- Show CPU and elapsed time
SET STATISTICS TIME ON;

SELECT 
    o.order_id,
    o.order_status,
    c.customer_city
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_status = 'delivered';

SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;

Interpreting STATISTICS IO Output

Table 'orders'. Scan count 1, logical reads 245, physical reads 3
Table 'customers'. Scan count 1, logical reads 89, physical reads 0
MetricMeaning
Scan countNumber of scans/seeks on the table
Logical readsPages read from memory (buffer cache)
Physical readsPages read from disk (not in cache)
Read-ahead readsPages prefetched from disk

Goal: Minimize logical reads. Compare before and after adding an index to see the improvement.

Identifying Performance Bottlenecks

Red Flags in Execution Plans

Red FlagWhat It MeansHow to Fix
Table Scan / Clustered Index ScanNo useful index for the queryAdd an appropriate index
Key Lookup (high cost)NC index found rows but needs more columnsAdd INCLUDE columns
Sort operatorData not pre-sorted by an indexAdd index matching ORDER BY
Hash Match for small joinsOptimizer chose hash because no indexAdd index on join column
Thick arrowsMany rows flowing between operatorsCheck if filters can reduce rows earlier
Estimated ≠ Actual rowsStale statisticsUPDATE STATISTICS
Parallelism (yellow)Query too expensive for single threadMay be normal, or query needs tuning
Warning iconsMissing stats, implicit conversions, etc.Address the specific warning

The Arrow Thickness Rule

In graphical plans, the thickness of arrows between operators represents the number of rows flowing. Thick arrows mean lots of data. Look for places where a thick arrow suddenly becomes thin — that is where filtering is happening. Ideally, filtering should happen as early (as far right) as possible.

Comparing Plans Before and After an Index

The best way to validate an index is to compare execution plans.

-- Step 1: Get the plan WITHOUT the index
SET STATISTICS IO ON;
SET STATISTICS TIME ON;

SELECT order_id, order_status, order_purchase_timestamp
FROM orders
WHERE order_status = 'shipped'
  AND order_purchase_timestamp >= '2018-01-01';
-- Note: Table Scan, logical reads = 500+

-- Step 2: Create the index
CREATE NONCLUSTERED INDEX IX_Orders_Status_Date
ON orders (order_status, order_purchase_timestamp)
INCLUDE (order_id);

-- Step 3: Get the plan WITH the index
SELECT order_id, order_status, order_purchase_timestamp
FROM orders
WHERE order_status = 'shipped'
  AND order_purchase_timestamp >= '2018-01-01';
-- Note: Index Seek, logical reads = 5

SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
MetricBefore IndexAfter Index
OperatorClustered Index ScanNonClustered Index Seek
Logical reads500+5
Estimated rowsAll rowsOnly matching rows
Key LookupN/ANone (covered by INCLUDE)

Summary

Here is what you learned in this tutorial:

ConceptKey Points
Estimated planGenerated before execution — no actual row counts
Actual planGenerated after execution — includes runtime stats
Read directionRight to left, bottom to top
Index SeekBest access method — navigates B-tree directly
Index ScanReads entire index — often a sign of missing index
Key LookupExpensive extra step — fix with INCLUDE columns
STATISTICS IOShows logical/physical reads per table
Thick arrowsIndicate many rows — look for early filtering
Estimated ≠ ActualStale statistics — run UPDATE STATISTICS

In the next tutorial, you will learn about SQL hints — how to override the optimizer's decisions when you know better.