Query Plan Analysis
A one-off diagnostic tool for investigating a specific slow query. Unlike every other tool in the suite, which analyzes fleet-wide DMV data on a schedule, this tool focuses on a single query's execution plan and returns actionable recommendations to fix it.
A standalone diagnostic tool, not wired into sp_IndexOptimizationSuite. You reach for it when someone says "this query is slow." Pass in a query to execute and analyze, or a pre-captured plan XML from SSMS.
Why This Exists
When a query is slow, the answer is almost always in the execution plan. But reading a raw XML plan is tedious, and the most impactful findings follow predictable patterns:
- Stale statistics → optimizer picks the wrong join strategy → catastrophic performance
- Missing indexes → full table scans where a seek would work
- Key lookups → double I/O on every row
- Implicit conversions → index seeks become scans silently
- Cardinality misestimates → nested loops where hash joins are needed
This tool automates the detection of all five patterns and generates the exact SQL to fix each one.
What It Detects
1. Stale Statistics
The #1 cause of bad execution plans. The optimizer uses statistics to estimate row counts. When statistics are stale (many modifications since last update, or sampled at a low rate), the optimizer makes wrong assumptions about how many rows will flow through each operator. This leads to catastrophic join strategy choices — for example, choosing a nested loop (good for 7 rows) when the actual result is 5 million rows.
How it works: Parses OptimizerStatsUsage/StatisticsInfo elements from the plan XML. Every statistic the optimizer considered during plan compilation is listed with its sample rate, modification count, and last update date. The tool flags stats that cross staleness thresholds.
Output: Specific UPDATE STATISTICS [schema].[table] [stat_name] WITH FULLSCAN; statements — stat names, not just table names. You can copy and run them directly.
2. Missing Indexes
SQL Server's optimizer sometimes embeds missing index hints directly in the execution plan when it knows a better index would help. These are high-confidence recommendations because the optimizer calculated them during actual plan compilation for this specific query.
How it works: Extracts MissingIndexes/MissingIndexGroup/MissingIndex elements from the plan XML. Parses equality columns (keys), inequality columns (appended to keys), and include columns. Generates the complete CREATE NONCLUSTERED INDEX statement.
3. Key Lookups
A key lookup occurs when the optimizer finds the right row via a nonclustered index seek, but then has to go back to the clustered index to fetch additional columns that aren't in the nonclustered index. This doubles the I/O for every row returned.
How it works: Detects RelOp[@PhysicalOp="Key Lookup"] operators in the plan. Reports the table, the clustered index being looked up, and the estimated row count and executions. The fix is to add the missing columns as INCLUDEs to the nonclustered index feeding the lookup.
4. Cardinality Misestimates
When the optimizer's row estimate for an operator is wildly different from reality, every downstream decision (join type, memory grant, parallelism) is wrong. This is the signature pattern of stale statistics — if you see 100x+ misestimates, look at the stale statistics findings first.
How it works: Compares @EstimateRows vs @ActualRows on RelOp nodes. Only available in actual execution plans (not estimated plans from @SQL mode). Flags operators where the ratio exceeds 100x in either direction.
5. Implicit Conversions
When a query compares a column to a parameter of a different data type, SQL Server silently inserts a CONVERT_IMPLICIT operation. If the conversion is on the column side (not the parameter side), it prevents index seeks entirely — the optimizer must scan the whole index because it can't seek on a converted column.
How it works: Searches for CONVERT_IMPLICIT in ScalarOperator/@ScalarString attributes throughout the plan XML. Reports the full conversion expression so you can identify which column and types are mismatched.
Two Modes of Operation
@SQL Mode — Execute and Analyze
Pass the query as a string. The proc executes it, then grabs the plan from the plan cache.
EXEC indexing.sp_AnalyzeQueryPlan
@SQL = 'SELECT o.OrderId, c.Name
FROM dbo.Orders o
JOIN dbo.Customers c ON o.CustomerId = c.Id
WHERE o.OrderDate > ''2024-01-01''',
@DatabaseName = 'MyDatabase';
How it works internally:
- Injects a unique GUID marker comment into the SQL:
/*AnalyzeQP_a1b2c3...*/ - Executes the query via
sp_executesql— results return to the caller (they'll scroll by) - Searches
sys.dm_exec_cached_plansfor the plan containing the GUID marker - Parses the cached plan XML with XQuery to extract findings
Note: The query actually executes, so its result set will scroll by before the analysis output appears. For expensive queries that take minutes to run, use @PlanXML mode instead.
@PlanXML Mode — Analyze Without Executing
Pass a pre-captured plan directly. No query execution. Use this when:
- You already have a
.sqlplanfile from SSMS (Ctrl+L for estimated, Ctrl+M for actual, then right-click → Save Execution Plan As) - The query is too expensive to re-run
- You want to analyze an actual plan with runtime row counts (for cardinality detection)
- You're analyzing a plan from a different server
DECLARE @plan XML = (SELECT BulkColumn FROM OPENROWSET(
BULK 'D:\path\to\file.sqlplan', SINGLE_BLOB) AS x);
EXEC indexing.sp_AnalyzeQueryPlan @PlanXML = @plan;
Stale Statistics Threshold Logic
The plan XML lists every statistic the optimizer considered during compilation — not just the ones causing problems. Naive filtering (e.g., "any stat with modifications") produces too much noise. The thresholds are calibrated to flag genuinely problematic stats while filtering out healthy ones:
| Sample Rate | Min Modifications | Rationale |
|---|---|---|
| < 5% | > 5,000 | Poorly sampled stat on a large table with meaningful changes. The optimizer is working with a tiny sample that may no longer represent the data distribution. |
| < 25% | > 25,000 | Moderately sampled but the data has drifted significantly. The histogram buckets may be stale. |
| Any | > 250,000 | Massive change volume regardless of sample quality. At 250K+ modifications, even a well-sampled stat is likely outdated. |
Why these numbers? In production testing, the original thresholds flagged stats at 98% sampling with 2,351 modifications — clearly not stale. The current thresholds correctly flag the real problem (e.g., 1.4% sampling with 24K–79K modifications on a large table) while ignoring healthy stats.
Output Format
The stored proc returns a result set with four columns, ordered by priority:
| Column | Description |
|---|---|
FindingType | STALE_STATISTICS, MISSING_INDEX, KEY_LOOKUP, CARDINALITY_ISSUE, IMPLICIT_CONVERSION, or ERROR |
Finding | What was found — stat name + table, index name, operator description |
Detail | Supporting data — sample %, modification count, estimated vs actual rows, conversion expression |
Recommendation | Ready-to-run SQL — UPDATE STATISTICS, CREATE INDEX, or diagnostic guidance |
Priority order ensures the most impactful fixes appear first. Stale statistics are ranked highest because they are the most common root cause of bad plans and the easiest to fix.
Typical Workflow
- Customer or monitoring reports a slow query
- Run
sp_AnalyzeQueryPlanwith the query or a saved plan file - Copy the
UPDATE STATISTICSstatements from the Recommendation column - Execute them on the target database
- Re-run
sp_AnalyzeQueryPlanwith the same query - If findings remain, address them (missing indexes, key lookups, etc.)
- Repeat until the result set is empty — the query is now running on a clean plan
Real example: A complex reporting query on a production database went from 25 minutes → 13 seconds → 9 seconds through two iterations of this workflow. The first pass flagged 14 stale statistics on the primary table (all at 1.4% sampling with 24K–79K modifications). After updating those, the second pass found a few remaining stale stats on secondary tables. After updating those, the query returned zero findings and ran in 9 seconds.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
@SQL | NVARCHAR(MAX) | NULL | Query to analyze. The proc executes it, then grabs the plan from cache via a unique GUID marker. Results scroll by before analysis. |
@PlanXML | XML | NULL | Pre-captured plan XML to analyze directly. No execution. Use for expensive queries or when you have a .sqlplan file. |
@DatabaseName | NVARCHAR(128) | NULL | Database context for @SQL mode. NULL = current database. Ignored when @PlanXML is provided. |
@Debug | BIT | 0 | When 1, returns the captured plan XML as an additional result set for manual inspection. |
One of @SQL or @PlanXML is required. If both are provided, @PlanXML takes precedence.
Installation
Standalone — not included in the suite install script. Deploy directly:
-- Run on each server where you want query plan analysis
USE DBA_Utilities;
GO
-- Execute the contents of query-plan-analysis.sql
Usage Examples
Quick diagnostic with @SQL
-- Analyze a slow query directly
EXEC indexing.sp_AnalyzeQueryPlan
@SQL = 'SELECT * FROM dbo.Orders WHERE CustomerId = 42 AND OrderDate > ''2024-01-01''',
@DatabaseName = 'MyDatabase';
Analyze a saved plan file
-- Load a .sqlplan file from SSMS
DECLARE @plan XML = (SELECT BulkColumn FROM OPENROWSET(
BULK 'D:\Plans\slow_query.sqlplan', SINGLE_BLOB) AS x);
EXEC indexing.sp_AnalyzeQueryPlan @PlanXML = @plan;
Debug mode — inspect the captured plan
-- Returns the plan XML as an extra result set
EXEC indexing.sp_AnalyzeQueryPlan
@SQL = 'SELECT COUNT(*) FROM dbo.LargeTable WHERE Status = ''Active''',
@DatabaseName = 'MyDatabase',
@Debug = 1;
Iterative fix workflow
-- Step 1: Find what's wrong
EXEC indexing.sp_AnalyzeQueryPlan @SQL = 'SELECT ...', @DatabaseName = 'MyDatabase';
-- Step 2: Fix the stale stats (copy from Recommendation column)
UPDATE STATISTICS [dbo].[Orders] [IX_Orders_CustomerId] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Orders] [IX_Orders_OrderDate] WITH FULLSCAN;
-- Step 3: Verify — should return fewer or zero findings
EXEC indexing.sp_AnalyzeQueryPlan @SQL = 'SELECT ...', @DatabaseName = 'MyDatabase';