name: sqlplan-review description: Analyze SQL Server execution plans for performance anti-patterns, bottleneck identification, and actionable fix recommendations. Applies 108 checks (S1–S36 statement-level, N1–N72 node-level) covering memory grants, parallelism, cardinality errors, spills, scans, index usage, IQP/PSP features, ADR, and CE feedback. Use this skill whenever a user pastes a .sqlplan file or XML, shares an SSMS execution plan, asks why a query is slow or regressed after a deployment or stats update, mentions a specific operator (Key Lookup, Hash Match, Sort, Nested Loops, Scan), asks about memory grants, spills, compile timeout, parameter sniffing, or plan shape. Also trigger when the user uploads a .sqlplan file, describes a plan tree verbally, or asks for execution plan review, plan analysis, or query tuning help. triggers: - /sqlplan-review - /plan-review
SQL Server Execution Plan Review Skill
Purpose
Analyze a SQL Server execution plan for performance anti-patterns and produce a prioritized, actionable report. Based on the same analysis ruleset used by commercial SQL Server execution plan tools. Covers 108 checks across statement-level (S1–S36) and node-level (N1–N72) categories.
Input
Accept any of:
- Raw
.sqlplanXML (paste or file contents) - A description of the plan tree (operator names, row counts, costs)
- A question like "why is this query slow?" with plan details included
If the user provides XML, extract the relevant attributes yourself before running checks. If the input is a description, apply the checks based on what is mentioned.
How to Run
A .sqlplan XML contains one or more <StmtSimple> elements (a single query, or many in a stored procedure).
For each <StmtSimple> in the XML:
- Record the
StatementIdand a short excerpt fromStatementTextfor the overview table label (use the fullStatementTextfor all checks — never truncate during analysis) - Run all 36 statement-level checks (S1–S36) against this statement's attributes
- Walk every
<RelOp>node in this statement's plan tree recursively, applying all 72 node-level checks (N1–N72) - Label every finding with the statement source
Single-statement plans (one <StmtSimple>): the StatementId prefix may be omitted for brevity.
Multi-statement plans (> 1 <StmtSimple>): every finding carries a StatementId label. See the multi-statement section in Output Format below.
Report every triggered finding — do not stop at the first match per statement. Walk all statements completely.
Thresholds Reference
| Metric | Value |
|---|---|
| Expensive operator | costPercent ≥ 25% |
| High-cost operator | costPercent ≥ 50% |
| Memory grant info | granted ≥ 512 MB |
| Large memory grant | granted ≥ 1,024 MB |
| Excessive memory grant | granted / used ≥ 10× AND granted ≥ 1 GB |
| Memory grant critical | ≥ 4,096 MB |
| Grant wait warning | > 0 ms |
| Grant wait critical | ≥ 5,000 ms |
| High compile CPU warning | ≥ 1,000 ms |
| High compile CPU critical | ≥ 5,000 ms |
| Downlevel CE | CardinalityEstimationModelVersion < 130 |
| Expensive scan | rowsRead / rowsReturned > 100× |
| Key lookup concern | actualRows > 1,000 OR actualExecutions > 1,000 |
| Sort spill risk | actualRows > estimateRows × 10 |
| Hash spill risk | probeRows > buildRows × 100 |
| High loop count (warning) | actualExecutions > 10,000 |
| High loop count (info) | actualExecutions > 1,000 with high inner cost |
| Bad row estimate (warning) | actual vs estimated > 1,000× in either direction |
| Bad row estimate (info) | actual vs estimated > 100× in either direction |
| Expensive sort | (estimateIO + estimateCPU) ≥ 50% of subtree cost |
| Busy loops | (rebinds + rewinds + 1) > estimateRows × 100 |
| Parallel efficiency low | < 50% AND speedup < DOP × 0.5 AND elapsed ≥ 1,000 ms |
| Large IN list | SeekPredicates with > 20 discrete seek ranges |
| Missing indexes excessive | > 5 MissingIndexGroup children in plan |
| Excessive parameters | > 50 ColumnReference children in ParameterList |
| Window frame large | RANGE UNBOUNDED PRECEDING with actualRows > 100,000 |
| Cached plan size (info) | CachedPlanSize ≥ 1,024 KB |
| Cached plan size (warning) | CachedPlanSize ≥ 5,120 KB |
| Memory request denied (warning) | RequestedMemory > GrantedMemory × 1.1 |
| Serial required memory (info) | SerialRequiredMemory ≥ 524,288 KB (512 MB) |
| Compile wait (info) | CompileTime > CompileCPU × 2 AND CompileTime > 1,000 ms |
| Wide row (warning) | AvgRowSize > 8,192 bytes |
| Wide row (critical) | AvgRowSize > 32,768 bytes |
| Wide output list (info) | OutputList ColumnReference count > 20 |
| Elapsed time hotspot | ActualElapsedms sum for operator > 1,000 ms AND > 50% of statement elapsed |
| Thread starvation | any RunTimeCountersPerThread ActualRows = 0 while total > 0 |
| Partition elimination failure | ActualPartitionsAccessed = PartitionCount with predicate present |
| Actual rebind excess | ActualRebinds > EstimateRebinds × 10 AND ActualRebinds > 1,000 |
Statement-Level Checks (S1–S36)
Run these once per <StmtSimple> element before inspecting individual operators.
S1 — Serial Plan
- Trigger:
NonParallelPlanReasonattribute is present ANDStatementSubTreeCost≥ 1.0 ANDStatementOptmLevel≠ TRIVIAL - Severity: Warning if reason is actionable (see below), Info otherwise
- Actionable reasons: MaxDOPSetToOne, QueryHintNoParallelSet, ParallelismDisabledByTraceFlag, CouldNotGenerateValidParallelPlan, TSQLUserDefinedFunctionsNotParallelizable, TableVariableTransactionsDoNotSupportParallelNestedTransaction
- Fix: Remove MAXDOP 1 hint, rewrite scalar UDFs as inline TVFs, replace table variables with temp tables, check server MAXDOP setting
S2 — Excessive Memory Grant
- Trigger:
GrantedMemory/MaxUsedMemory≥ 10× ANDGrantedMemory≥ 1,048,576 KB - Severity: Warning
- Fix: Add
OPTION (OPTIMIZE FOR (@param = value)), update statistics, useOPTION (RECOMPILE)to get a per-execution grant
S3 — Large Memory Grant
- Trigger:
GrantedMemory≥ 524,288 KB (512 MB) for Info; ≥ 1,048,576 KB (1 GB) for Warning; ≥ 4,194,304 KB (4 GB) for Critical - Severity: Info (≥ 512 MB); Warning (≥ 1 GB); Critical (≥ 4 GB)
- Fix: Reduce sort/hash operations, filter earlier in the plan, check for stale statistics causing row overestimates. The 512 MB Info tier surfaces plans that are large but not yet alarming — worth noting before they grow.
S4 — Memory Grant Wait
- Trigger:
GrantWaitTime> 0 - Severity: Warning; Critical if
GrantWaitTime≥ 5,000 ms - Fix: Reduce memory grant size (see S2/S3), add Resource Governor pool, or increase
max server memory
S5 — Compile Timeout
- Trigger:
StatementOptmEarlyAbortReason= TimeOut - Severity: Critical
- Fix: Break the query into smaller pieces, use query hints to guide the optimizer, eliminate unnecessary joins or subqueries, consider a stored procedure with forced plan
S6 — Compile Memory Exceeded
- Trigger:
StatementOptmEarlyAbortReason= MemoryLimitExceeded - Severity: Critical
- Fix: Simplify the query, reduce the number of tables/joins, split into multiple queries
S7 — High Compile CPU
- Trigger:
CompileCPU≥ 1,000 ms - Severity: Warning if < 5,000 ms, Critical if ≥ 5,000 ms
- Fix: Use
OPTION (RECOMPILE)sparingly, parameterize the query, use plan guides, reduce query complexity
S8 — Ineffective Parallelism
- Trigger:
DegreeOfParallelism> 1 ANDelapsedTimeMs≥ 1,000 AND parallel efficiency < 50% - Calculation: speedup = cpuTimeMs / elapsedTimeMs; efficiency = ((speedup − 1) / (DOP − 1)) × 100
- Severity: Warning
- Fix: Investigate thread synchronization, reduce DOP via MAXDOP hint, check for skew in data distribution across threads
S9 — Parallel Wait Bottleneck
- Trigger:
elapsedTimeMs>cpuTimeMs× 2 (threads spending more time waiting than working) - Severity: Warning
- Fix: Look for repartition streams, gather streams operators; check for blocking, lock waits, or I/O contention
S10 — Downlevel Cardinality Estimator
- Trigger:
CardinalityEstimationModelVersion> 0 AND < 130 - Severity: Warning
- Fix: Update database compatibility level to 130+ (SQL 2016+), or use
OPTION (USE HINT('FORCE_DEFAULT_CARDINALITY_ESTIMATION'))to use the current compat level's CE, orQUERY_OPTIMIZER_COMPATIBILITY_LEVEL_n(SQL 2017 CU10+) at query level. Test first — some queries perform better on the old CE.
S11 — Plan-Level Warnings
- Trigger:
<Warnings>element exists under<QueryPlan> - Severity: Warning
- Fix: Inspect the specific warning type. Common types: SpillToTempDb, NoJoinPredicate, PlanAffectingConvert
S12 — Implicit Conversion Affects Seek
- Trigger:
<PlanAffectingConvert ConvertIssue="Seek Plan">present in Warnings - Severity: Critical
- Fix: Match the data type of the parameter/literal to the column type. Common mismatch: VARCHAR column with NVARCHAR parameter, or INT column with VARCHAR literal.
S13 — Table Variable (Read)
- Trigger: Any node has
objectNamestarting with@and statement is not a modification - Severity: Warning
- Fix: Replace with a temporary table (
#temp) so statistics are available, especially when the table variable holds > ~100 rows
S14 — Table Variable (Write / Modification)
- Trigger: Any node has
objectNamestarting with@and a write operator (Insert/Update/Delete) targets it - Severity: Critical
- Fix: Replace with a temp table. Writing to a table variable forces single-threaded execution regardless of DOP.
S15 — High Compile Memory
- Trigger:
CompileMemory≥ 1,048,576 KB (1 GB) onStmtSimple - Severity: Warning
- Fix: The optimizer consumed over 1 GB of memory just to compile this query. Simplify joins and subqueries. Use stored procedures to promote plan reuse and avoid repeated expensive compilations.
S16 — Trivial Plan
- Trigger:
StatementOptmLevel= TRIVIAL ANDStatementSubTreeCost≥ 1.0 - Severity: Info
- Fix: SQL Server bypassed full optimization and used a trivial plan. Usually benign, but if performance is poor, check for missing indexes or consider forcing full optimization with a query hint.
S17 — Unparameterized Query
- Trigger: No
<ParameterList>element present onStmtSimpleANDStatementType= SELECT/INSERT/UPDATE/DELETE (not stored procedure) - Severity: Info
- Fix: The query has no parameters — it may be an ad-hoc query with literal values baked in. Each unique set of literals produces a new plan cache entry. Use parameterized queries or
sp_executesqlto improve plan reuse and reduce plan cache bloat.
S18 — Insufficient Memory Grant (Used > Granted)
- Trigger:
MemoryGrantInfo/@MaxUsedMemory>MemoryGrantInfo/@GrantedMemory(query used more memory than it was granted) - Severity: Warning — always Warning regardless of the magnitude of under-allocation. The confirmed spills caused by this under-grant are caught as Critical via N41/N38; do not escalate S18 itself.
- Fix: The memory grant was undersized because the optimizer underestimated row counts at compile time. This causes the query to spill to tempdb. Fix root-cause cardinality errors (parameter sniffing, stale statistics). Unlike S2/S3 which flag over-allocation, this flags the opposite — the grant was too small.
S19 — FORCE ORDER Hint
- Trigger:
StatementTextmatches/OPTION\s*\([^)]*FORCE\s*ORDER/i - Severity: Warning
- Fix: FORCE ORDER freezes the join order from the query text, overriding the optimizer's cost-based join reordering. Becomes incorrect as data distribution changes. Remove the hint and fix the root cause (missing statistics, missing indexes) so the optimizer can choose the correct order itself.
S20 — RECOMPILE Hint with Expensive Compile
- Trigger:
StatementTextcontainsOPTION (RECOMPILE)ANDCompileCPU≥ 500 ms; Critical ifCompileCPU≥ 2,000 ms - Severity: Warning / Critical
- Fix: OPTION (RECOMPILE) discards the plan after every execution. At high compile CPU, every execution pays a heavy compilation tax. Use
OPTIMIZE FORorOPTION (OPTIMIZE FOR UNKNOWN)instead. If parameter sniffing is the root cause, address it with filtered statistics or local variable sniffing-prevention.
S21 — Recursive CTE Without Max Recursion
- Trigger:
StatementTextcontainsWITH ... ASand a self-referencing CTE name AND noOPTION (MAXRECURSION N)is present - Severity: Warning
- Fix: Add
OPTION (MAXRECURSION N)to avoid runaway recursion on bad data. The default limit is 100; an explicit limit documents intent and prevents accidental infinite loops when hierarchy data has cycles.
S22 — SET ROWCOUNT Active
- Trigger:
RowCountAssignmentattribute > 0 onStmtSimple[Unverified — attribute not found in documented showplan references; also detectSET ROWCOUNTin the batch text] - Severity: Warning
- Fix:
SET ROWCOUNTis deprecated, silently changes plan shapes, and can truncate results without warning. The optimizer builds the plan assuming the full result set will be returned;SET ROWCOUNTtruncates silently at execution. Sort operators are sized for all rows, indexes are chosen for full-scan patterns, and row goals are not applied. Replace withTOP (N)—TOPis a compile-time directive the optimizer can see, enabling row goals, seek strategies, and right-sized memory grants for N rows rather than all rows.
S23 — Excessive Parameter Count
- Trigger:
<ParameterList>has > 50<ColumnReference>children - Severity: Info
- Fix: Very high parameter counts inflate plan cache entry size and compile time. Consider batching via table-valued parameters (
CREATE TYPE ... AS TABLE) or splitting into smaller parameterized queries.
S24 — Query Store Forced Plan Active
- Trigger:
PlanGuideNameattribute starts withQDS_onStmtSimple - Severity: Warning
- Fix: A Query Store forced plan is overriding normal optimization. QDS-forced plans bypass the optimizer and become stale as data changes. Validate the forced plan is still beneficial and that the underlying regression (bad statistics, missing index) has been resolved. If fixed, unforce via
sys.sp_query_store_unforce_plan.
S25 — Interleaved Execution (MSTVF) Active
- Trigger:
ContainsInterleavedExecutionCandidates = trueon theQueryPlannode (per-operatorIsInterleavedExecutedappears onRuntimeInformation) — SQL 2017+ - Severity: Info
- Fix: SQL Server is using interleaved execution to feed actual row counts from multi-statement TVFs back into optimization. This is beneficial. Verify it has not been suppressed via
OPTION (USE HINT('DISABLE_INTERLEAVED_EXECUTION_TVF')), which would revert to the static 1-row estimate.
S26 — Batch Mode Adaptive Join Active
- Trigger: Any operator has
IsAdaptive = 1ANDexecutionMode = Batch— SQL 2017+ (compat level 140+) - Severity: Info
- Fix: SQL Server is deferring the join strategy (Hash vs Nested Loops) to runtime. This is generally good. Flag only if the
AdaptiveThresholdRowsdoes not match actual row distribution, indicating the threshold was calibrated on a non-representative execution.
S27 — Excessive Missing Index Suggestions
- Trigger:
<MissingIndexes>element contains > 5<MissingIndexGroup>children - Severity: Warning
- Fix: More than 5 distinct missing index suggestions indicate the query touches many under-indexed tables. Prioritize by the
Impactattribute descending (not document order). Use thesqlindex-advisorskill to consolidate and de-duplicate suggestions before creating indexes.
S28 — Large Cached Plan (Plan Cache Bloat)
- Trigger:
CachedPlanSizeattribute on<QueryPlan>≥ 1,024 KB - Severity: Info if < 5,120 KB; Warning if ≥ 5,120 KB
- Fix: Large cached plans consume plan cache memory and increase the cost of plan cache lookup on every execution. Common causes: queries with many joins, many parameters (see S23), or dynamic SQL with large literals baked in. Parameterize the query or split into smaller units. Also run:
SELECT TOP 10 usecounts, size_in_bytes, text FROM sys.dm_exec_cached_plans CROSS APPLY sys.dm_exec_sql_text(plan_handle) ORDER BY size_in_bytes DESC;
S29 — Memory Request Denied by Server
- Trigger:
RequestedMemory>GrantedMemory× 1.1 inMemoryGrantInfo(the optimizer requested more memory than the server could grant) - Severity: Warning
- Fix: The server was under memory pressure at execution time and reduced the grant below what was requested. This is distinct from S4 (grant wait, which measures delay) — this shows the request was cut. Sort and hash operators will spill to TempDb even when statistics are accurate. Increase
max server memory, add Resource Governor, or reduce concurrent memory demand from other queries.
S30 — High Serial Required Memory
- Trigger:
SerialRequiredMemory≥ 524,288 KB (512 MB) inMemoryGrantInfo - Severity: Info
- Fix: Even in serial mode (DOP 1), this query needs 512 MB+ just for its sort and hash operators. This is an absolute size problem independent of parallelism. Filter data earlier in the plan, add indexes to avoid sorts, or reduce the number of sort/hash operations in the query.
S31 — Non-QDS Forced Plan (Plan Guide)
- Trigger:
PlanGuideNameattribute present onStmtSimpleAND does NOT start withQDS_ - Severity: Warning
- Fix: A traditional
sp_create_plan_guideis forcing this plan — distinct from S24 which catches Query Store forced plans. Traditional plan guides are fragile: they break silently when the query text changes, when statistics update dramatically, or when the hinted plan's index is dropped. Validate the guide is still beneficial:SELECT * FROM sys.plan_guides WHERE name = '<PlanGuideName>';then capture the current plan without the guide and compare with/sqlplan-compare.
S32 — Compile Wall-Clock vs CPU Gap (Compilation Contention)
- Trigger:
CompileTime>CompileCPU× 2 ANDCompileTime> 1,000 ms (wall-clock compile time significantly exceeds CPU time) - Severity: Info
- Fix: SQL Server spent compile time waiting rather than working — typically a latch contention on plan cache bucket locks, or memory pressure forcing the optimizer to wait.
CompileTimeis wall-clock;CompileCPUis CPU-only. A large gap means idle CPU during compilation. Checksys.dm_os_wait_statsforRESOURCE_SEMAPHORE_QUERY_COMPILEwaits. UseOPTION (RECOMPILE)sparingly or plan guides to reduce compile frequency.
S33 — Non-Standard Compilation SET Options
- Trigger:
StatementSetOptionselement onStmtSimplehasQuotedIdentifier="false"ORAnsiNulls="false"ORAnsiWarnings="false" - Severity: Info
- Fix: The plan was compiled with non-standard SET options — usually because the application sets
SET ANSI_NULLS OFForSET QUOTED_IDENTIFIER OFF. This creates a separate plan cache entry from SSMS-compiled plans (SSMS always uses standard options), causing plan cache bloat. It also affects query semantics:SET ANSI_NULLS OFFchanges how NULL comparisons work, andSET QUOTED_IDENTIFIER OFFallows double-quoted strings. Align application connection options with SQL Server defaults.
S34 — Parameter Sensitive Plan Dispatcher Detected
- Trigger:
ParameterSensitivePredicateelement or a<Dispatcher>element present in the plan XML — SQL 2022+ (compat level 160) only - Severity: Info
- Fix: SQL Server 2022 PSP optimization created a dispatcher plan with multiple sub-plans for different parameter value ranges. Verify each variant is healthy by checking
sys.query_store_query_variant. If a specific parameter range selects the wrong variant, use Query Store hints (sys.sp_query_store_set_hints) to override variant selection for that range. Related: N68.
S35 — ADR Long-Transaction Version Store Accumulation
- Trigger: Accelerated Database Recovery (ADR) is active on the database (inferred from plan XML DB context or user description) AND
logusedor transaction duration signals a long-running transaction — SQL 2019+ only - Severity: Warning
- Fix: ADR moves the persistent version store (PVS) to TempDB. Long-running transactions under ADR cause PVS to grow continuously until the transaction commits or rolls back. Keep transactions short and monitor PVS size with
sys.dm_tran_persistent_version_store_stats. Cross-reference E29 in sqlerrorlog-review.
S36 — Cardinality Estimation Feedback Applied
- Trigger:
CardinalityFeedbackattribute present in the Showplan XML — SQL 2022+ only. Cross-check withsys.query_store_plan_feedbackwherefeature_desc = 'CE Feedback' - Severity: Info
- Fix: The CE model was automatically adjusted by feedback across prior executions. This is generally beneficial but means the plan's cardinality estimates no longer reflect the base CE model. Monitor stability: if query performance fluctuates across executions after CE feedback applies, the feedback model may be oscillating. Use Query Store to track plan history. Related: Q27 in sqlquerystore-review.
Node-Level Checks (N1–N72)
Apply these to every operator node in the plan tree.
N1 — Filter Late in Plan
- Trigger:
physicalOp= Filter AND predicate is present AND children exist AND (child elapsed ≥ 10 ms OR child subtree cost ≥ 1.0) - Severity: Warning
- Fix: Push the filter condition into the WHERE clause or earlier join condition. Add an index that allows the predicate to be applied as a seek or residual predicate closer to the data source.
N2 — Eager Index Spool
- Trigger:
logicalOp= Eager Spool AND operator name contains "index" - Severity: Critical
- Why Critical: The spool combines the cost of a full scan, a TempDB write, and a B-tree build before any seeks can begin. Every execution pays this full construction cost afresh — unlike a permanent index which is built once. On hot-path procedures the spool cost is paid on every call, making it cumulative across all executions.
- Fix: SQL Server is building a temporary index at runtime because a suitable index does not exist. Add a permanent index matching the spool's seek predicate. Check the Missing Indexes section first.
N3 — Function on Scan Predicate
- Trigger: Operator is a scan AND predicate contains any of: UPPER, LOWER, SUBSTRING, LEFT, RIGHT, LTRIM, RTRIM, REPLACE, CAST, CONVERT, ISNULL, COALESCE, CASE, ABS, CEILING, FLOOR, ROUND, DATEADD, DATEDIFF, DATEPART, YEAR, MONTH, DAY, GETDATE, GETUTCDATE, SYSUTCDATETIME, TRY_CONVERT, PARSE, TRY_PARSE
- Severity: Warning
- Fix: Rewrite the predicate to be sargable. Examples:
WHERE YEAR(OrderDate) = 2024→WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01'WHERE UPPER(Name) = 'FOO'→ use a case-insensitive collation or a computed column with an index
N4 — Expensive Scan
- Trigger: Operator is a scan AND
actualRowsRead/actualRows> 100× (only when actual stats present) - Severity: Warning
- Fix: Add an index with the scan's predicate columns as key columns. If the scan is on a large table, this is your primary optimization target.
N5 — Key Lookup / RID Lookup at Scale
- Trigger:
physicalOpis Key Lookup or RID Lookup AND (actualRows> 1,000 ORactualExecutions> 1,000) - Severity: Warning if
costPercent≥ 25%, Info otherwise - Fix: Extend the non-clustered index to include (INCLUDE columns) the columns being fetched in the lookup. This eliminates the lookup entirely.
N6 — Sort Spill Risk
- Trigger:
physicalOp= Sort AND actual stats present ANDactualRows>estimateRows× 10 - Severity: Warning
- Fix: Update statistics on the table(s) feeding the sort. If spilling is confirmed (check sys.dm_exec_query_stats or Extended Events), add an index that returns data pre-sorted, or increase sort memory with Resource Governor.
N7 — Hash Spill Risk
- Trigger:
physicalOp= Hash Match AND actual stats present AND probe side rows > build side rows × 100 - Severity: Warning
- Fix: Update statistics. Consider adding an index to make the build side smaller, or rewrite the join order so the smaller table is the build input. Use
OPTION (HASH JOIN)to prevent plan flips.
N8 — Implicit Conversion in Predicate
- Trigger: Predicate text contains "convert" or "implicit"
- Severity: Warning
- Fix: Align data types between the column and the parameter/literal. Inspect
sys.dm_exec_plan_attributesandsys.dm_exec_cached_plansfor parameter sniffing issues.
N9 — Leading Wildcard LIKE
- Trigger: Predicate contains
LIKEfollowed immediately by a quote character (',") or% - Severity: Warning
- Fix: Leading wildcards (
LIKE '%foo') prevent index seeks and force full scans. Options: Full-Text Search (CONTAINS), reverse-indexed column, or an application-level search strategy.
N10 — No Join Predicate (Cartesian Product)
- Trigger:
NoJoinPredicateflag = 1 or true on the Warnings element of the node - Severity: Critical
- Fix: Almost always a bug. Verify the JOIN or WHERE clause includes all intended conditions. If a cross join is intentional, add a comment confirming intent. A cartesian product multiplies row counts: two 1,000-row tables produce 1,000,000 output rows; three tables produce 1 billion. Memory grants, hash build sizes, and elapsed time scale with the product — not the sum — of input sizes. A missing join predicate on large tables is one of the fastest ways to exhaust server memory and saturate TempDB.
N11 — Columns With No Statistics
- Trigger:
<ColumnsWithNoStatistics>element present in node Warnings - Severity: Warning
- Fix: Run
UPDATE STATISTICS <table>or enable Auto Create Statistics. The optimizer is using a fixed 1-row estimate, which almost always leads to a suboptimal plan.
N12 — Backward Scan
- Trigger:
ScanDirection= BACKWARD - Severity: Warning
- Fix: Add a DESC index that matches the ORDER BY direction, or rewrite the query to avoid reversing the scan direction. Backward scans have higher CPU cost than forward scans. SQL Server's read-ahead prefetching is forward-only; backward scans cannot benefit from it, increasing the random-I/O fraction. Latch contention also increases because page latches are acquired out of allocation order. The overhead is proportional to row count — negligible on small seeks, significant on full-index backward scans.
N13 — MSTVF Bad Row Estimate
- Trigger:
logicalOp= "Table-valued function" ANDestimateRows= 1 or 100 - Severity: Warning
- Fix: SQL Server cannot estimate multi-statement TVF output. Rewrite as an inline TVF (single SELECT statement) so the optimizer can see through it. In SQL 2017+ with compatibility level 140+, Interleaved Execution revises MSTVF estimates automatically.
N14 — TVF Inside Join
- Trigger:
logicalOp= "Table-valued function" AND parent operator is any join type - Severity: Warning
- Fix: TVF row estimates are unreliable (see N13). A bad estimate here can force a nested loops join where a hash join would be far faster. Materialize the TVF into a temp table first, then join.
N15 — High Nested Loop Count
- Trigger:
physicalOp= Nested Loops ANDactualExecutions> 10,000 (Warning); Info if > 1,000 AND inner subtreeestimatedTotalSubtreeCost≥ 0.5 - Severity: Warning (> 10,000 executions); Info (> 1,000 with non-trivial inner cost)
- Fix: This is often an N+1 query pattern. Consider Hash Match or Merge Join. Check if an index on the inner side would reduce the per-iteration cost. Look for missing indexes on the inner table's join columns. The threshold hierarchy (Warning at 10,000, Info at 1,000 with inner cost ≥ 0.5) reflects that loops with a cheap inner side are often benign, but high loops with a non-trivial inner side are almost always a join-strategy error. Even a per-iteration cost of 0.001, repeated 10,000 times, totals 10 units — but if the inner estimate was 0.001 and actual is 0.1, real cost is 1,000 units.
N16 — Busy Loop Pattern
- Trigger:
physicalOp= Nested Loops AND (rebinds + rewinds + 1) >estimateRows× 100 - Severity: Warning
- Fix: The optimizer expects many loops but few output rows. This is a row goal optimization gone wrong. Use
OPTION (DISABLE_OPTIMIZER_ROWGOAL)(SQL 2016+) or restructure the query to eliminate the row goal.
N17 — Row Goal Applied
- Trigger:
EstimateRowsWithoutRowGoal> 0 - Severity: Info
- Fix: The optimizer reduced its row estimate to optimize for returning the first N rows fast (e.g., due to TOP, EXISTS, FAST N hint). This is normal but can cause full-scan plans when more rows are needed. If the full result set is always consumed, use
OPTION (DISABLE_OPTIMIZER_ROWGOAL).
N18 — Adaptive Join
- Trigger:
IsAdaptive= 1 or true - Severity: Info
- Fix: No action required. SQL Server will choose between Hash Match and Nested Loops at runtime based on actual row counts. If the adaptive threshold is firing unexpectedly, check for parameter sniffing.
N19 — ColumnStore in Row Mode
- Trigger:
storageType= ColumnStore ANDexecutionMode= Row - Severity: Warning
- Fix: Batch mode is 5–10× faster for ColumnStore. Mixed row/column joins, scalar UDFs, or compatibility level < 130 can force row mode. Remove scalar UDFs, ensure compatibility level ≥ 130, and avoid mixing row-store and column-store tables in the same query when possible.
N20 — Many-to-Many Merge Join
- Trigger:
ManyToMany= 1 or true on the Merge element - Severity: Warning
- Fix: A worktable is being written to TempDB. Ensure the join keys are unique on at least one side, or use a Hash Match join instead. Check for missing unique constraints or indexes.
N21 — Bad Row Estimate
- Trigger: Actual stats present AND (
estimateRows× 1,000 <actualRowsORestimateRows>actualRows× 1,000) for Warning; same check at 100× threshold for Info - Severity: Warning (> 1,000× mismatch); Info (100×–999× mismatch)
- Fix: Update statistics (
UPDATE STATISTICS <table> WITH FULLSCAN). Investigate parameter sniffing (OPTION (RECOMPILE)orOPTIMIZE FOR). Consider a filtered statistic if the skew is on a specific value range. The 100× Info tier is an early warning; the 1,000× Warning tier indicates the optimizer is likely choosing the wrong join strategy.
N22 — Expensive Sort
- Trigger:
physicalOp= Sort AND (estimateIO+estimateCPU) ≥ 50% ofestimatedTotalSubtreeCostAND parent exists - Severity: Warning
- Fix: Add an index whose key columns match the ORDER BY expression and direction. This lets SQL Server avoid the sort entirely by reading data pre-ordered.
N23 — Remote Query
- Trigger:
physicalOpcontains "Remote" - Severity: Warning
- Fix: Remote operators (linked servers, OPENQUERY) add network latency and reduce optimizer visibility. The optimizer cannot see remote statistics at compile time, so it uses a fixed 1-row estimate for the remote side of any join — the same cardinality collapse as N13/N21, but structural and not fixable with statistics updates. A 1-row estimate on a table that returns 1 million rows forces nested loops where hash join is needed, on every execution. Pull data locally into a temp table first, or use a distributed view. Avoid JOINs between local and remote tables in the same query.
N24 — High Cost Operator
- Trigger:
costPercent≥ 50% - Severity: Info
- Fix: This is your primary optimization target. Focus all index and query rewrite efforts on reducing the cost of this operator before tuning anything else.
N25 — Scalar UDF Execution
- Trigger:
physicalOpcontains "UDF" OR a<UserDefinedFunction>element is present on the operator - Severity: Warning
- Fix: Scalar UDFs execute once per row and prevent batch mode and parallelism. Rewrite as an inline table-valued function (iTVF) using a single SELECT statement, or inline the logic directly into the query.
N26 — Exchange Spill
- Trigger:
physicalOpcontains "Parallelism" ANDSpillLevel> 0 ORSpillCount> 0 on the operator - Severity: Warning
- Fix: The exchange iterator ran out of memory and spilled to TempDB. Fix row estimates feeding the parallel exchange. Increase memory if the grant is too small, or reduce DOP to lower memory pressure.
N27 — Parallel Thread Skew
- Trigger: Actual stats present AND
physicalOp= "Parallelism" AND max thread rows / avg thread rows > 2× - Severity: Warning
- Fix: Work is unevenly distributed across threads, limiting parallel speedup. Investigate data skew on the partitioning column. Consider a different distribution key or use HASH partitioning hints.
N28 — Lazy Spool Ineffective
- Trigger:
logicalOp= "Lazy Spool" AND actual stats present ANDActualRebinds>ActualRewinds× 10 - Severity: Warning
- Fix: The spool cache is rarely reused (high rebinds vs rewinds), making it a net cost rather than a benefit. Investigate why the outer loop produces many unique values. Adding an index on the inner side may eliminate the need for the spool.
N29 — Join OR Clause
- Trigger: Any join operator (
physicalOp= Hash Match, Merge Join, or Nested Loops) whose predicate text containsOR - Severity: Warning
- Fix: OR predicates in joins prevent seek operations and force SQL Server to expand the join into multiple lookup iterations. Rewrite using UNION ALL to split the OR branches, or use a covering index on each branch column.
N30 — CTE Multiple References
- Trigger: A Spool operator (
logicalOp= Eager Spool or Lazy Spool) is present ANDStatementTextcontains a CTE declaration (WITH ... AS) - Severity: Warning
- Fix: CTEs referenced more than once are re-evaluated on each reference — there is no automatic materialization. Materialize the CTE into a #temp table to compute it once, then reference the temp table multiple times.
N31 — Top Above Scan
- Trigger:
logicalOp= "Top" AND the direct child operator is a Scan withcostPercent≥ 25% - Severity: Warning
- Fix: TOP is reading rows from a full scan when an index could provide pre-ordered rows, allowing SQL Server to stop early. Add an index whose key columns match the ORDER BY and WHERE clauses to enable an index seek with early termination.
N32 — OPTIMIZE FOR UNKNOWN
- Trigger:
StatementTextmatches/OPTIMIZE\s+FOR\s+.*UNKNOWN/i - Severity: Info
- Fix: OPTIMIZE FOR UNKNOWN forces the optimizer to use average column density instead of actual parameter values, which can produce plans that are mediocre for all values instead of optimal for common ones. Remove the hint and test; if parameter sniffing is the root cause, address it with filtered indexes, plan guides, or OPTION (RECOMPILE) on the specific problematic executions.
N33 — NOT IN with Nullable Column
- Trigger:
logicalOp= "Row Count Spool" AND actual stats present ANDActualRewinds> 1000 - Severity: Warning
- Fix: A high-rewind Row Count Spool typically indicates a
NOT INagainst a nullable column. SQL Server must verify the absence of NULLs on every iteration. Rewrite asNOT EXISTSor add aWHERE col IS NOT NULLfilter on the subquery to eliminate the NULL-safety check.
N34 — Wide Index Suggestion
- Trigger: A
MissingIndexGroupsuggestion contains > 4 key columns OR > 5 INCLUDE columns - Severity: Info
- Fix: Wide index suggestions are often the result of the optimizer combining multiple independent access patterns. A wide index is costly to maintain and may not be the right solution. Evaluate the suggestion critically — split into narrower targeted indexes, or address the queries individually to reduce column requirements.
N35 — Estimated Plan CE Guess
- Trigger: Estimated plan only (no runtime stats) AND operator is a Scan AND selectivity (
EstimateRows/TableCardinality) matches a known CE default: 30%, 10%, 9%, 16.4%, or 1% (± 0.5%) - Severity: Info
- Fix: The optimizer is using a hardcoded selectivity guess because no statistics exist for the predicate column. Create statistics on the filtered column:
CREATE STATISTICS [stat_col] ON table (col). These telltale percentages are reliable indicators of missing statistics.
Node-Level Checks (N36–N66, continued)
N36 — Forced Plan
- Trigger:
PlanGuideNameattribute is present onStmtSimpleORStatementTextcontainsUSE PLAN - Severity: Warning
- Fix: A plan guide or USE PLAN hint is forcing the optimizer to use a specific plan. This can mask underlying issues (bad statistics, missing indexes). Validate that the forced plan is still appropriate — forced plans become stale as data and schema change.
N37 — Unmatched Indexes
- Trigger:
<UnmatchedIndexes>element is present under<QueryPlan> - Severity: Warning
- Fix: An index hint was specified but SQL Server could not use it (wrong columns, filtered index mismatch, etc.). The optimizer fell back to a different access path. Remove or correct the index hint, or create an index that matches the hint exactly.
N38 — Operator-Level Warnings
- Trigger: A
<Warnings>element is present as a direct child of a<RelOp>node (distinct from the plan-level<Warnings>caught by S11) - Severity: Warning
- Fix: Individual operators have flagged warnings — common causes include sort spills, hash spills, and residual I/O issues. Inspect each operator's warning type and address the root cause (statistics, indexes, memory).
N39 — Heap Scan
- Trigger:
physicalOp= "Table Scan" (indicates a scan on a heap — a table with no clustered index) - Severity: Warning
- Fix: Heap scans read every row with no ordering guarantees. Add a clustered index to the table to enable ordered access and reduce I/O. If the table is intentionally a heap (e.g., staging table), add a nonclustered index on the filter column instead. On heavily updated heaps, forwarded record pointers add extra random I/O: each moved row leaves a pointer, and every scan must follow it. Heaps cannot use efficient read-ahead prefetching (which assumes allocation order). On active-write heaps, actual I/O can be 2–4× what row count alone implies.
N40 — Forced Index / Seek / Scan Hint
- Trigger:
ForcedIndex= 1,ForceSeek= 1, orForceScan= 1 attribute on anyRelOp - Severity: Warning
- Fix: An INDEX, FORCESEEK, or FORCESCAN hint is overriding the optimizer's access path choice. Hints become incorrect as data grows and statistics change. Remove the hint and let the optimizer choose, or ensure the hinted index is kept up to date and the hint is still beneficial.
N41 — Confirmed Spill to TempDb
- Trigger:
<SpillToTempDb SpillLevel="N">element present underQueryPlan/WarningswithSpillLevel> 0 (requires an actual execution plan, not estimated) - Severity: Warning if
SpillLevel= 1; Critical ifSpillLevel≥ 2 - Fix: The sort or hash operator ran out of memory and wrote to tempdb. Fix root-cause cardinality errors (parameter sniffing, stale statistics) so the optimizer requests an adequate memory grant. If estimates are correct but spills persist, increase
min memory per queryvia Resource Governor. Unlike N6/N7 which flag spill risk from estimates, this is a confirmed actual spill.
N42 — Implicit Conversion Degrades Cardinality
- Trigger:
<PlanAffectingConvert ConvertIssue="Cardinality">element present inQueryPlan/Warnings - Severity: Warning
- Fix: An implicit type conversion is distorting the cardinality estimator's histogram lookup, causing it to fall back to a default density vector instead of the actual histogram. This causes wrong join strategies and memory grants even when seeks are still possible. Match the data types of the column and parameter to eliminate the conversion entirely.
N43 — Residual Predicate on Index Seek
- Trigger: A Seek operator (
PhysicalOpcontains "Seek") has both<SeekPredicates>AND<Predicate>child elements present, AND when runtime data is availableactualRows / actualRowsRead< 0.1 (seek retrieves 10× more rows than it returns) - Severity: Warning
- Fix: The index navigates to matching rows via the seek predicate, but then a residual predicate filters out most of them at the leaf level — wasting I/O on rows that are discarded. Extend the index key to include the residual predicate column (make it a key column, not INCLUDE) so the seek can filter during B-tree traversal rather than at the leaf.
N44 — Many Joins (Greedy Optimizer Threshold)
- Trigger: Count of join operators (
PhysicalOp= Hash Match, Merge Join, or Nested Loops) ≥ 8 in the plan - Severity: Info
- Fix: SQL Server's optimizer uses exhaustive join reordering up to approximately 7–8 tables, then switches to greedy heuristics that may miss the optimal order. This can combine with S5 (compile timeout) to produce a suboptimal plan. Exhaustive enumeration evaluates all join-order permutations (5,040 for 7 tables) and picks the cheapest. Greedy starts from the cheapest two-way join and never backtracks. For skewed data — where joining Table A to B first reduces rows by 99% but that is only apparent after comparing A×C — greedy locks in a catastrophically wrong order. Splitting via temp tables is counterintuitive but necessary: break the query into smaller units using temp tables or CTEs materialised into temp tables to reduce the join count below the greedy threshold.
N45 — Non-Index Eager Spool (Halloween Protection / Subquery Materialisation)
- Trigger:
LogicalOp= "Eager Spool" ANDPhysicalOpdoes NOT contain "Index" AND cost ≥ 10% of plan (distinguishes from N2 which catches index spools) - Severity: Warning
- Fix: A non-index Eager Spool (Table Spool) caches a full subtree into a worktable. This typically indicates Halloween protection (DML statement reads and writes the same table — unavoidable) or subquery materialisation. For DML, restructure using a staging temp table. For subqueries, rewrite as a JOIN so the optimizer has more flexibility to avoid the spool.
N46 — Window Aggregate Without Partition
- Trigger:
physicalOp= "Window Aggregate" or "Sequence Project" AND no<Partition>element is present in the window specification - Severity: Warning
- Fix: A window function with no PARTITION BY runs over the entire result set as a single partition. If this is intentional (e.g.,
ROW_NUMBER() OVER (ORDER BY col)for a global rank), no fix is needed. If a partition key was omitted accidentally, addPARTITION BYto scope the window — this also allows parallelism across partitions.
N47 — Window Aggregate RANGE Frame (Spool Risk)
- Trigger: A Window Spool / Window Aggregate operator is present AND the statement text uses a
RANGE UNBOUNDED PRECEDINGframe (explicitly or as the default frame of an ORDER BY-only OVER clause) AND actual stats present ANDactualRows> 100,000 - Severity: Warning
- Fix:
RANGE UNBOUNDED PRECEDINGuses an internal spool that writes one row per pass.ROWS UNBOUNDED PRECEDINGdoes not. If there are no duplicate ORDER BY values in the window (or duplicates don't affect correctness), changeRANGEtoROWSin the OVER clause — this eliminates the spool and is 2–10× faster on large datasets.
N48 — In-Memory OLTP Cross-Container Join
- Trigger: Any operator node has
StorageType = InMemoryAND a sibling operator within the same join hasStorageType = RowStore - Severity: Warning
- Fix: Mixing memory-optimized and disk-based tables in a single join forces a cross-container execution context. This prevents natively compiled execution and limits DOP. Separate the workloads: read the memory-optimized table into a
#temptable, then join against disk-based tables in a separate step.
N49 — Columnstore Segment Elimination Not Occurring
- Trigger:
physicalOpcontains "Columnstore" AND runtime stats present ANDSegmentSkips= 0 ANDSegmentReads> 10 (runtime counters; STATISTICS IO reports them as "segment reads N, segment skipped M") - Severity: Warning
- Fix: Zero segments were eliminated by the predicate — the filter column has no natural sort order within rowgroups. On SQL 2022+, rebuild the columnstore index with
ORDER (col)to sort rowgroups. On earlier versions, restructure data loads so rows arrive pre-sorted on the filter column. Without elimination, every query does a full columnstore scan.
N50 — Columnstore Delta Store Read
- Trigger:
physicalOpcontains "Columnstore" AND runtime stats present ANDDeltaStoreRows> 0 - Severity: Info
- Fix: Open delta stores (not yet compressed rowgroups) are being scanned row-by-row, negating columnstore batch-mode benefits for those rows. This is expected immediately after inserts. If delta stores persist (check
sys.dm_db_column_store_row_group_physical_statsfor OPEN rowgroups with large row counts), force compression:ALTER INDEX ... REORGANIZE WITH (COMPRESS_ALL_ROW_GROUPS = ON).
N51 — Batch Mode on Rowstore (SQL 2019+)
- Trigger:
executionMode= Batch ANDstorageType!= ColumnStore — SQL 2019+ - Severity: Info
- Fix: SQL Server is applying batch mode execution to a rowstore table — a SQL 2019 feature. This is beneficial and typically 2–4× faster for aggregation-heavy queries. No action required. If you see this disabled on similar queries, check for scalar UDFs or row-mode-only operators blocking batch mode propagation.
N52 — Constant Scan
- Trigger:
physicalOp= "Constant Scan" - Severity: Info
- Fix: A Constant Scan produces a fixed set of rows without reading any table. This is normal for
VALUESlists, system function results, and CTEs folded at compile time. An unexpected Constant Scan — especially when a large table was expected — may indicate aWHERE 1=0condition, a parameter value that eliminated all rows at compile time, or a contradiction in the predicate. Verify the plan was compiled with representative parameter values.
N53 — Assert Operator
- Trigger:
physicalOp= "Assert" - Severity: Info
- Fix: An Assert operator enforces a constraint (CHECK, referential integrity, or uniqueness) at runtime. High-cost or high-execution Assert nodes mean constraint validation is a measurable bottleneck. For bulk DML, disable constraints with
ALTER TABLE ... NOCHECK CONSTRAINT, load, re-enable and re-validate. For FK lookups, ensure the referenced table has a covering index on the FK key column.
N54 — Lazy Spool on Correlated Subquery (Ineffective Cache)
- Trigger:
logicalOp= "Lazy Spool" AND parent operator is Nested Loops AND runtime stats present ANDActualRewinds> 1,000 - Severity: Warning
- Fix: The spool is attempting to cache the inner side of a correlated subquery, but high rewinds indicate the outer loop produces many unique values, causing cache misses on every iteration. The spool provides no benefit and adds overhead. Rewrite the correlated subquery as a JOIN or
CROSS APPLYwith a derived table so the optimizer can use a hash or merge strategy instead.
N55 — Large IN List Expanded to Seek Ranges
- Trigger: A Seek operator has
SeekPredicateswith > 20 discrete seek ranges (from anINlist expansion) - Severity: Warning
- Fix: SQL Server expands
WHERE col IN (v1, v2, ...)into individual seek ranges. Above ~20 values, a#temptable + JOIN is more efficient and gives the optimizer accurate cardinality:INSERT #ids VALUES ...; SELECT ... FROM table JOIN #ids ON id = #ids.id. This also avoids plan cache bloat from distinct literal sets.
N56 — Cross Apply with High-Cost Correlated Inner Side
- Trigger:
physicalOp= "Nested Loops" ANDLogicalOp= "Inner Join" ANDOuter Referencesare present (correlated apply) AND inner subtreeestimatedTotalSubtreeCost≥ 1.0 ANDactualExecutions> 1,000 - Severity: Warning
- Fix: A
CROSS/OUTER APPLYis re-executing an expensive correlated subquery once per outer row. Materialize the inner side into a#temptable (pre-joined or pre-aggregated), then join the temp table to the outer set. This allows the optimizer to use a hash or merge join strategy and avoids repeated inner execution.
N57 — STRING_SPLIT at Scale
- Trigger:
physicalOp= "Table-valued function" AND the operator name or object reference contains "STRING_SPLIT" ANDactualRows> 10,000 - Severity: Warning
- Fix: STRING_SPLIT has no statistics — the optimizer always estimates 50 output rows regardless of the input string. At scale, this causes join strategy errors and memory undersizing. For large volumes, pre-split strings in the application layer or load them into a staging table. On SQL 2022+, pass
1as the third argument (enable_ordinal) to expose theordinaloutput column if positional ordering is needed:STRING_SPLIT(col, ',', 1); without it the ordinal column is not returned.
N58 — Columnstore Plan with Mixed Batch/Row Mode Operators
- Trigger: The plan contains operators with
executionMode = BatchAND other operators withexecutionMode = Rowwhen a columnstore index is present as a data source - Severity: Warning
- Fix: Mixed batch/row mode means the optimizer could not propagate batch mode across the entire plan. Batch mode is 5–10× faster for analytical operators. Common causes: scalar UDFs (rewrite as inline TVFs), row-mode-only join types, or version/compat-level limitations. Check for scalar UDF references (N25) and ensure compatibility level ≥ 130.
N59 — Index Seek on Column With No Statistics
- Trigger: A Seek operator has
<SeekPredicates>AND a<ColumnsWithNoStatistics>warning on the same operator node - Severity: Warning
- Fix: The seek is using a column with no statistics histogram. The optimizer falls back to a fixed default selectivity (see N35 for the known default percentages), which will be wrong for any non-uniform distribution. Run
UPDATE STATISTICS <table>or create statistics explicitly:CREATE STATISTICS [stat_col] ON table (col). This is especially harmful when the seek feeds a nested loops join — a wrong estimate here propagates into every downstream operator.
N60 — Non-Sargable JSON Predicate
- Trigger: Predicate text contains
JSON_VALUE(orJSON_QUERY(in a filter position (WHERE clause or join predicate) - Severity: Warning
- Fix: JSON path functions evaluated in WHERE clauses are computed per row and cannot use index seeks. Options: (1) Add a computed column
AS JSON_VALUE(col, '$.path') PERSISTEDand create an index on it — seeks will use the computed column index. (2) On SQL 2022+, use the native JSON index:CREATE INDEX ... ON table (col) INCLUDE (json_col) WHERE JSON_VALUE(json_col, '$.path') IS NOT NULL. (3) Filter JSON parsing to the application layer when the result set is small enough.
N61 — High Estimated Average Row Size
- Trigger: Any operator node has
AvgRowSize> 8,192 bytes; Critical if > 32,768 bytes (the showplan attribute isAvgRowSize; SSMS displays it as "Estimated Row Size") - Severity: Info if > 8,192 bytes; Warning if > 32,768 bytes
- Fix:
AvgRowSizeis the width (in bytes) of a single row passing through this operator. When rows exceed one 8-KB page, sort and hash operators must allocate at least one buffer page per row — multiplying memory grant requirements dramatically. This is the hidden root cause of unexpectedly large memory grants. Fix: stop projecting columns that are not needed downstream. ReplaceSELECT *with explicit column lists. A 4,000-byte row in a sort of 1 million rows requires ~4 GB of sort memory — checkRequestedMemory(S29) alongside this check.
N62 — Actual Elapsed Time Hotspot
- Trigger: An operator's total
ActualElapsedmsacross all threads (sum ofRunTimeCountersPerThread/@ActualElapsedms) > 1,000 ms AND represents > 50% of total statement elapsed time (requires actual execution plan) - Severity: Warning
- Fix: This operator is the dominant wall-clock bottleneck — not just the highest estimated cost (N24), but the actual time sink at runtime. Estimated cost (N24) reflects the optimizer's model; actual elapsed time reflects I/O waits, lock waits, and memory pressure that cost models do not account for. Focus optimization effort on this operator first regardless of its estimated cost percentage.
N63 — Thread Starvation (Zero-Row Thread)
- Trigger: A
Parallelismoperator has one or moreRunTimeCountersPerThreadentries withActualRows = 0while the total across threads is > 0 (requires actual execution plan) - Severity: Warning
- Fix: One or more parallel threads processed zero rows while others did all the work. This is a stronger signal than N27 (skew ratio) — a zero-row thread consumed full thread setup and teardown overhead with zero productive contribution. Causes: hash distribution on a column where all values hash to the same bucket (extreme skew), or partition-aware parallelism where all data falls on one partition. Fix the partitioning column or use
OPTION (MAXDOP 1)if parallelism consistently starves threads.
N64 — Wide Projection (SELECT * Anti-Pattern)
- Trigger: A Scan or Seek operator's
<OutputList>contains > 20<ColumnReference>children - Severity: Info
- Fix: The scan is projecting more than 20 columns upward through the plan tree. Every downstream Sort, Hash Match, or Nested Loops operator carries this wide row, inflating memory grants (see N61), row buffer sizes, and network I/O. Identify the SELECT list in the query text and replace
SELECT *with only the columns actually needed. This is especially impactful when the scan feeds a sort or hash join — each wide row multiplies the operator's memory requirement.
N65 — Partition Elimination Not Occurring
- Trigger: A scan operator has
Partitioned="1"(orPartitionedScanelement present) ANDActualPartitionsAccessedequals the full partition count of the table AND a predicate on the partition column exists (requires actual execution plan) - Severity: Warning
- Fix: The query has a predicate on the partition key but SQL Server scanned all partitions anyway — partition elimination failed. Common causes: (1) implicit type conversion on the partition column (matches N8/N42); (2) predicate uses a function wrapping the partition column (matches N3); (3) the partition scheme uses a computed expression that the optimizer cannot simplify at compile time. Fix the predicate to be sargable on the partition column type. After fixing, actual partitions accessed should drop to 1 or a small subset.
N66 — Actual Rebinds Exceed Estimated Rebinds
- Trigger:
PhysicalOp= Nested Loops ANDActualRebinds>EstimateRebinds× 10 ANDActualRebinds> 1,000 (requires actual execution plan) - Severity: Warning
- Fix: The Nested Loops operator executed far more inner-side iterations than the optimizer estimated at compile time.
EstimateRebindscomes from the outer side cardinality estimate; when the actual outer side is much larger, every under-estimated join drives N66. This is a complement to N16 (Busy Loop based on estimates alone) that fires on actual execution evidence. Fix: correct the cardinality error on the outer side of the join (statistics update, parameter sniffing fix), or force a Hash Match join that is less sensitive to outer cardinality:INNER HASH JOIN.
N67 — Ordered Columnstore Scan Segment Pruning Confirmed
- Trigger: Operator contains
physicalOp= Columnstore Index Scan ANDOrdered="true"ANDSegmentSkips≥ (SegmentReads+SegmentSkips) × 0.5 — SQL 2022+ (ordered columnstore index,CREATE INDEX ... ORDER (col)) - Severity: Info
- Fix: The ordered columnstore index is working as intended — at least 50% of segments were eliminated. Report the pruning ratio (
SegmentSkips / (SegmentReads + SegmentSkips)) as a positive signal. If pruning is lower than expected, verify the ORDER column in the index matches the query's filter predicate column.
N68 — PSP Variant Cardinality Error
- Trigger: Inside a PSP dispatcher plan, an individual variant node has
actualRows / estimateRows> 100 ANDactualRows> 1,000 — SQL 2022+ only; requires actual plan - Severity: Warning
- Fix: A PSP variant has a severe cardinality error despite being specialized for a parameter range. The variant's threshold boundary does not match the actual data skew. Use
sys.query_store_query_variantto inspect variant boundaries and adjust using Query Store hints or by disabling PSP for this query with theDISABLE_PARAMETER_SENSITIVE_PLANhint (database scoped configurationPARAMETER_SENSITIVE_PLAN_OPTIMIZATION = OFFfor the whole database).
N69 — IQP Approximate Count Distinct Active
- Trigger: An aggregate operator's defined values or the statement text reference
APPROX_COUNT_DISTINCT— SQL 2019+ IQP feature - Severity: Info
- Fix: IQP Approximate Count Distinct is in use, producing an estimate within approximately 2% of the true distinct count. Confirm with the query author that approximate results are acceptable. If exact count semantics are required (financial reconciliation, constraint validation), replace
APPROX_COUNT_DISTINCT(col)withCOUNT(DISTINCT col)— the function choice is explicit in the query text, so the fix is a query change.
N70 — DOP Feedback Adjusted Plan
- Trigger:
DegreeOfParallelismFeedbackelement present in the plan [Unverified — element not found in documented showplan references; DOP feedback state is reliably visible insys.query_store_plan_feedbackwithfeature_desc = 'DOP Feedback'] — SQL 2022+ IQP DOP Feedback feature - Severity: Info
- Fix: IQP DOP Feedback automatically reduced this query's degree of parallelism based on observed thread utilization. The adjusted DOP should improve CPU efficiency and reduce CXPACKET waits. Monitor for stability: if DOP feedback oscillates between values across executions, the workload arrival pattern is irregular and the feedback model may not stabilize.
N71 — Adaptive Join Threshold Evaluation
- Trigger: Operator
physicalOp= Adaptive Join ANDAdaptiveThresholdRowsis present — SQL 2017+ - Severity: Info
- Fix: Report
AdaptiveThresholdRowsvsactualRowson the outer side. IfactualRowsis consistently above the threshold, the adaptive join always becomes Hash Match — consider making the Hash Match explicit. IfactualRowsis consistently below the threshold, the join always uses Nested Loops — consider removing the adaptive join overhead with aLOOP JOINhint. IfactualRowsstraddles the threshold across executions, the adaptive join is earning its place.
N72 — Low Statistics Sampling Percent on Hot Statistics
- Trigger:
StatisticsInfo/@SamplingPercent< 10 for any statistic whose associated table hasactualRows> 100,000 — actual plan only; skip entirely ifStatisticsInfoelements are absent from the plan XML - Severity: Warning — the optimizer compiled this plan using a statistic built from a very small sample; the histogram has fewer steps and reduced resolution, increasing the risk of poor cardinality estimates under data skew even when the statistic was recently updated
- Fix: Rebuild the flagged statistic with a higher sample:
UPDATE STATISTICS <table> (<stat_name>) WITH FULLSCAN. To prevent future auto-updates from reverting to the low rate, addPERSIST_SAMPLE_PERCENT = ON(SQL 2016 SP1 CU4+, Azure SQL):UPDATE STATISTICS <table> (<stat_name>) WITH FULLSCAN, PERSIST_SAMPLE_PERCENT = ON. Identify the statistic name and table fromStatisticsInfo/@Statisticsand@Tablein the plan XML. If the table is large and FULLSCAN is too slow, useWITH SAMPLE 30 PERCENT, PERSIST_SAMPLE_PERCENT = ONas a compromise. Cross-reference N21 — ifactualRowsalready diverges fromestimateRows, the low sample rate is the likely root cause. - Related checks: N21 (bad row estimate — the downstream effect of low-quality stats), N11 (no statistics at all), N35 (CE default selectivity guess — also caused by absent or low-quality stats)
Version-Aware Check Suppression
If the SQL Server version is known — from the ServerVersion attribute in the plan XML or stated by the user — read VERSION_COMPATIBILITY.md (~/.claude/skills/VERSION_COMPATIBILITY.md if installed, or skills/VERSION_COMPATIBILITY.md from the repo). If unavailable, skip silently. For checks whose minimum version exceeds the instance version: verbose mode → log as SKIP (version: requires SQL 20XX+, instance is SQL 20YY); standard report → omit entirely. Do not suppress NOT ASSESSED rows from missing input — only suppress version-inapplicable checks.
Output Format
Structure your report as follows. The reference output in skills/sqlplan-review/examples/horrible-analysis.md
demonstrates the expected quality level — reading it once shows how findings link to each other,
how the fix sequence table resolves multiple checks, and how the Passed Checks table signals completeness.
Section: Summary
## Execution Plan Analysis
### Summary
- **N statements** in plan (omit for single-statement plans)
- **X Critical** issues, **Y Warnings**, **Z Info** items
- Primary bottleneck: [one sentence identifying the root cause and which operators it affects]
Multi-Statement Plans (conditional — apply when > 1 <StmtSimple>)
Statement Overview table (precedes findings):
### Statement Overview
| StmtId | Text (excerpt) | Cost | Rows Est | Compile CPU | DOP | Memory Grant |
|--------|----------------|------|----------|-------------|-----|--------------|
| 1 | SELECT u.* ... | 98.7K| 1 | 512 ms | 8 | 1,048,576 KB |
| 2 | INSERT INTO ...| 0.1 | 1,000 | 15 ms | 1 | N/A |
Finding labels include StatementId for every finding:
### [C1 — Statement 1, S4] Memory Grant Wait — 5,000 ms
Passed Checks: For multi-statement plans, omit the Passed Checks table. Per-statement triggered findings are exhaustive; a cross-statement PASS enumeration is unwieldy and redundant. Single-statement plans keep the Passed Checks table as prescribed.
Section: Findings (Critical / Warnings / Info)
Each finding header must include the check ID that fired:
### [C1 — S4] Issue Name — key metric
- **Observed:** [exact values from the XML — operator name, NodeId, row counts, cost]
- **Impact:** [why this matters at runtime — concurrency, I/O, elapsed time]
- **Fix:** [concrete action with code where applicable]
Rules:
Including the bracket suffix (
— S4,— N21, etc.) links the finding back to its check definition, making the report auditable and allowing users to cross-referencereferences/check-explanations.mdfor deeper fix options.Use schema-qualified names in Observed lines when the plan XML includes a
Schemaattribute on<Object>or<RelOp>— this prevents ambiguity in multi-tenant databases where identically-named tables exist in different schemas. Format:[Schema].[Table]preserving SQL Server bracket notation, orSchema.Tablein prose. Example:dbo.OrdersnotOrders;[dbo].[Orders].[IX_Orders_Status]in DDL. When the plan XML omits the Schema attribute (estimated plans, simplified XML), bare table names are acceptable.Findings reference each other by ID where one is the root cause of another (e.g. "see W7", "caused by W4").
N21 pervasive cardinality collapse (fires on > 3 operators): replace the bullet list with a table:
| NodeId | Operator | Estimated | Actual | Ratio | |--------|----------|-----------|--------|-------| | 1 | ... | 1 | ... | ...× |
Info section — parameter sniffing
If <ParameterList> shows ParameterCompiledValue ≠ ParameterRuntimeValue on any parameter,
report it as a named Info item — parameter sniffing buried in prose notes tends to be missed,
and it is almost always the root cause of the N21 cardinality errors above it:
### [I1] Parameter Sniffing — @ParamName compiled 'X', runtime 'Y'
- **Observed:** ParameterCompiledValue="X" vs ParameterRuntimeValue="Y"
- **Impact:** [how this explains the N21 estimate errors above]
- **Fix options:** [four SQL options]
See references/output-format.md for the four-option fix template with SQL.
S25, S26, N17, N32, and N52 findings also go in the Info section (labeled by statement in multi-statement plans).
Section: Missing Indexes
### Missing Indexes
#### XML-Suggested Indexes
For each MissingIndexGroup in the plan XML:
- Write the full CREATE INDEX statement using the database/schema from the XML.
- If the query has a non-sargable predicate on the indexed column (leading wildcard LIKE,
implicit conversion, wrapped function), add a blockquote warning:
> **Warning:** This index will NOT help with [predicate] because [reason]. Fix the predicate
> (see Wx) before creating this index.
#### Recommended Additional Indexes
After the XML suggestions, add analyst-inferred indexes that are NOT in the XML but are implied
by the findings — for example:
- A covering index to eliminate a Key Lookup (N5 finding) — include the INCLUDE columns needed
- An index on a join column to allow a Seek instead of Scan when N15 fires at scale
- Indexes on the build/probe inputs of a Hash Match when N7 fires
Use comments to explain which finding each index addresses.
Section: Prioritized Fix Sequence
End findings with a fix-sequence table — without it, users must read all findings to extract an
action plan. The table distills the report into a prioritized checklist.
Order by: (a) fixes that unblock others first, (b) highest severity, (c) lowest effort.
Reference finding IDs (e.g. C1, W4) in a Resolves column.
See references/output-format.md for the exact table template.
Section: Passed Checks
Include every check evaluated but not triggered as a two-column | Check | Result | table.
A complete PASS table signals the full ruleset was applied — omitting it signals an incomplete review.
End the table with the attribution line:
*Analyzed by: [AI model and version] · [date/time UTC or user's local timezone]*
See references/output-format.md for the full table template and NOT ASSESSED conventions.
Section: Output Filters (--brief / --critical-only)
--brief — Omit the Passed Checks table and attribution footer. Output the Summary, Findings, and Prioritized Fix Sequence sections only. Use when a quick scan of what fired is all that's needed.
--critical-only — Suppress Warning and Info findings. Show only Critical findings. The Passed Checks table is also omitted. Use when triaging an incident and only actionable blockers matter.
Both flags can be combined: --brief --critical-only produces the Summary section plus Critical findings only.
When neither flag is present, produce the full report as documented above.
Section: Verbose Output (--verbose)
When the user's request includes --verbose, --trace, or the word verbose:
1. Append a ## Check Evaluation Log section after the Passed Checks table.
Include one row for every check in this skill's ruleset, in check-ID order:
| Check | Evidence | Threshold | Result |
|---|---|---|---|
| [ID — Name] | [key attribute(s) and value found, or "absent"] | [threshold or condition] | PASS / FIRE → [severity] / NOT ASSESSED |
Result conventions:
PASS— attribute present, threshold not met**FIRE → Critical/Warning/Info**— threshold met; bold to distinguish from passesNOT ASSESSED— required attribute absent from input
2. Save both files to the current working directory using the Write tool:
output/
Derive <input-prefix>:
- Filename stem if a file path was provided (e.g.
horrible.sqlplan→horrible) - First meaningful identifier from the artifact (top wait type, first table name, procedure name, etc.)
- Fallback:
runSanitize: alphanumeric + hyphens/underscores only, max 32 chars.
File headers:
analysis.md → # Analysis — <skill-name> / # Input: <first 80 chars> / # Generated: <UTC timestamp>
trace.md → # Check Evaluation Log — <skill-name> / # Input: <first 80 chars> / # Generated: <UTC timestamp>
Create directories as needed. When --verbose is not present, write nothing to disk.
Limitations
- When actual execution stats are absent (estimated-only plan), skip checks that require actual rows/elapsed time and note this in the Passed Checks table as
NOT ASSESSED. - For checks where the threshold is ambiguous from the description, state your assumption explicitly.
- If the user provides only a partial plan (one operator), analyze what is visible and note what cannot be assessed.
- Do not invent warnings not triggered by the rules above. If nothing fires, say the plan is clean.
Companion Skills
tsql-review — Analyze the T-SQL source code of this query before capturing a plan. Catches static anti-patterns (SQL injection, non-sargable predicates, cursor usage, deprecated syntax) that are detectable without execution.
sqlstats-review — Parse and analyze
SET STATISTICS IO, TIME ONoutput for the same query. Provides per-table IO counts and timing that cross-reference operator behavior visible in this plan.sqlplan-compare — Diff two execution plans (baseline vs regression) to identify what changed in join strategies, memory grants, and operator topology.
sqlindex-advisor — Consolidate and de-duplicate missing index recommendations from one or more plans into a ranked, ready-to-run
CREATE INDEXscript.sqldeadlock-review — Analyze SQL Server deadlock XML to identify root cause (lock order, missing index, isolation level) and produce a remediation plan.
sqlplan-batch — Batch-analyze a folder of
.sqlplanfiles and produce a summary dashboard of top issues, most common violations, and deduplicated missing indexes across all plans.sqlquerystore-review — Analyze Query Store data to find regressed queries, plan instability, and the top resource consumers across the whole workload. Use after running a workload capture to prioritize which queries to tune with /sqlplan-review.
mssql-performance-review — Orchestrator that routes mixed artifacts to multiple specialised skills (this one included), runs an adversarial root-cause check, and produces a single consolidated report with evidence chain, risk-rated fixes, and rollback. Use when you have several artifact types together or describe a symptom without knowing which skill to run.
Reference Files
Load references/check-explanations.md when:
- A check fires and the user asks "what does this mean?" or needs ranked fix options beyond the primary fix above
- You need XML attribute examples or SQL code samples to verify a finding
The file is 3,500+ lines. Navigate with its Contents table at the top:
- Before You Start — key concepts (execution plans, statistics, memory grants)
- Statement-Level Checks (S1–S36) — XML attribute examples per check
- Node-Level Checks (N1–N72) — ranked fix options per check
- Quick Reference Tables — severity/trigger summary for all 108 checks
Load references/output-format.md when producing the Prioritized Fix Sequence,
Passed Checks table, or parameter-sniffing fix options in the final report.