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.
Accept any of:
- Raw
.sqlplan XML (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
StatementId and a short excerpt from StatementText for the overview table label (use the full StatementText for 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:
NonParallelPlanReason attribute is present AND StatementSubTreeCost ≥ 1.0 AND StatementOptmLevel ≠ 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× AND GrantedMemory ≥ 1,048,576 KB - Severity: Warning
- Fix: Add
OPTION (OPTIMIZE FOR (@param = value)), update statistics, use OPTION (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 AND elapsedTimeMs ≥ 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, or QUERY_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
objectName starting 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
objectName starting 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) on StmtSimple - 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 AND StatementSubTreeCost ≥ 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 on StmtSimple AND StatementType = 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_executesql to 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:
StatementText matches /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:
StatementText contains OPTION (RECOMPILE) AND CompileCPU ≥ 500 ms; Critical if CompileCPU ≥ 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 FOR or OPTION (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:
StatementText contains WITH ... AS and a self-referencing CTE name AND no OPTION (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:
RowCountAssignment attribute > 0 on StmtSimple [Unverified — attribute not found in documented showplan references; also detect SET ROWCOUNT in the batch text] - Severity: Warning
- Fix:
SET ROWCOUNT is 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 ROWCOUNT truncates silently at execution. Sort operators are sized for all rows, indexes are chosen for full-scan patterns, and row goals are not applied. Replace with TOP (N) — TOP is 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:
PlanGuideName attribute starts with QDS_ on StmtSimple - 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 = true on the QueryPlan node (per-operator IsInterleavedExecuted appears on RuntimeInformation) — 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 = 1 AND executionMode = 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
AdaptiveThresholdRows does 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
Impact attribute descending (not document order). Use the sqlindex-advisor skill to consolidate and de-duplicate suggestions before creating indexes.
S28 — Large Cached Plan (Plan Cache Bloat)
- Trigger:
CachedPlanSize attribute 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 in MemoryGrantInfo (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) in MemoryGrantInfo - 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:
PlanGuideName attribute present on StmtSimple AND does NOT start with QDS_ - Severity: Warning
- Fix: A traditional
sp_create_plan_guide is 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 AND CompileTime > 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.
CompileTime is wall-clock; CompileCPU is CPU-only. A large gap means idle CPU during compilation. Check sys.dm_os_wait_stats for RESOURCE_SEMAPHORE_QUERY_COMPILE waits. Use OPTION (RECOMPILE) sparingly or plan guides to reduce compile frequency.
S33 — Non-Standard Compilation SET Options
- Trigger:
StatementSetOptions element on StmtSimple has QuotedIdentifier="false" OR AnsiNulls="false" OR AnsiWarnings="false" - Severity: Info
- Fix: The plan was compiled with non-standard SET options — usually because the application sets
SET ANSI_NULLS OFF or SET 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 OFF changes how NULL comparisons work, and SET QUOTED_IDENTIFIER OFF allows double-quoted strings. Align application connection options with SQL Server defaults.
S34 — Parameter Sensitive Plan Dispatcher Detected
- Trigger:
ParameterSensitivePredicate element 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
logused or 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:
CardinalityFeedback attribute present in the Showplan XML — SQL 2022+ only. Cross-check with sys.query_store_plan_feedback where feature_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:
physicalOp is Key Lookup or RID Lookup AND (actualRows > 1,000 OR actualExecutions > 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 AND actualRows > 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_attributes and sys.dm_exec_cached_plans for parameter sniffing issues.
N9 — Leading Wildcard LIKE
- Trigger: Predicate contains
LIKE followed 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:
NoJoinPredicate flag = 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" AND estimateRows = 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 AND actualExecutions > 10,000 (Warning); Info if > 1,000 AND inner subtree estimatedTotalSubtreeCost ≥ 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 AND executionMode = 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 < actualRows OR estimateRows > 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) or OPTIMIZE 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% of estimatedTotalSubtreeCost AND 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:
physicalOp contains "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:
physicalOp contains "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:
physicalOp contains "Parallelism" AND SpillLevel > 0 OR SpillCount > 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 AND ActualRebinds > 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 contains OR - 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 AND StatementText contains 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 with costPercent ≥ 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:
StatementText matches /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 AND ActualRewinds > 1000 - Severity: Warning
- Fix: A high-rewind Row Count Spool typically indicates a
NOT IN against a nullable column. SQL Server must verify the absence of NULLs on every iteration. Rewrite as NOT EXISTS or add a WHERE col IS NOT NULL filter on the subquery to eliminate the NULL-safety check.
N34 — Wide Index Suggestion
- Trigger: A
MissingIndexGroup suggestion 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:
PlanGuideName attribute is present on StmtSimple OR StatementText contains USE 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, or ForceScan = 1 attribute on any RelOp - 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 under QueryPlan/Warnings with SpillLevel > 0 (requires an actual execution plan, not estimated) - Severity: Warning if
SpillLevel = 1; Critical if SpillLevel ≥ 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 query via 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 in QueryPlan/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 (
PhysicalOp contains "Seek") has both <SeekPredicates> AND <Predicate> child elements present, AND when runtime data is available actualRows / 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" AND PhysicalOp does 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, add PARTITION BY to 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 PRECEDING frame (explicitly or as the default frame of an ORDER BY-only OVER clause) AND actual stats present AND actualRows > 100,000 - Severity: Warning
- Fix:
RANGE UNBOUNDED PRECEDING uses an internal spool that writes one row per pass. ROWS UNBOUNDED PRECEDING does not. If there are no duplicate ORDER BY values in the window (or duplicates don't affect correctness), change RANGE to ROWS in 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 = InMemory AND a sibling operator within the same join has StorageType = 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
#temp table, then join against disk-based tables in a separate step.
N49 — Columnstore Segment Elimination Not Occurring
- Trigger:
physicalOp contains "Columnstore" AND runtime stats present AND SegmentSkips = 0 AND SegmentReads > 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:
physicalOp contains "Columnstore" AND runtime stats present AND DeltaStoreRows > 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_stats for 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 AND storageType != 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
VALUES lists, system function results, and CTEs folded at compile time. An unexpected Constant Scan — especially when a large table was expected — may indicate a WHERE 1=0 condition, 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.
- Trigger:
logicalOp = "Lazy Spool" AND parent operator is Nested Loops AND runtime stats present AND ActualRewinds > 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 APPLY with 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
SeekPredicates with > 20 discrete seek ranges (from an IN list expansion) - Severity: Warning
- Fix: SQL Server expands
WHERE col IN (v1, v2, ...) into individual seek ranges. Above ~20 values, a #temp table + 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.
- Trigger:
physicalOp = "Nested Loops" AND LogicalOp = "Inner Join" AND Outer References are present (correlated apply) AND inner subtree estimatedTotalSubtreeCost ≥ 1.0 AND actualExecutions > 1,000 - Severity: Warning
- Fix: A
CROSS/OUTER APPLY is re-executing an expensive correlated subquery once per outer row. Materialize the inner side into a #temp table (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" AND actualRows > 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
1 as the third argument (enable_ordinal) to expose the ordinal output 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 = Batch AND other operators with executionMode = Row when 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( or JSON_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') PERSISTED and 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 is AvgRowSize; SSMS displays it as "Estimated Row Size") - Severity: Info if > 8,192 bytes; Warning if > 32,768 bytes
- Fix:
AvgRowSize is 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. Replace SELECT * with explicit column lists. A 4,000-byte row in a sort of 1 million rows requires ~4 GB of sort memory — check RequestedMemory (S29) alongside this check.
N62 — Actual Elapsed Time Hotspot
- Trigger: An operator's total
ActualElapsedms across all threads (sum of RunTimeCountersPerThread/@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
Parallelism operator has one or more RunTimeCountersPerThread entries with ActualRows = 0 while 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" (or PartitionedScan element present) AND ActualPartitionsAccessed equals 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 AND ActualRebinds > EstimateRebinds × 10 AND ActualRebinds > 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.
EstimateRebinds comes 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 AND Ordered="true" AND SegmentSkips ≥ (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 AND actualRows > 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_variant to inspect variant boundaries and adjust using Query Store hints or by disabling PSP for this query with the DISABLE_PARAMETER_SENSITIVE_PLAN hint (database scoped configuration PARAMETER_SENSITIVE_PLAN_OPTIMIZATION = OFF for 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) with COUNT(DISTINCT col) — the function choice is explicit in the query text, so the fix is a query change.
N70 — DOP Feedback Adjusted Plan
- Trigger:
DegreeOfParallelismFeedback element present in the plan [Unverified — element not found in documented showplan references; DOP feedback state is reliably visible in sys.query_store_plan_feedback with feature_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 AND AdaptiveThresholdRows is present — SQL 2017+ - Severity: Info
- Fix: Report
AdaptiveThresholdRows vs actualRows on the outer side. If actualRows is consistently above the threshold, the adaptive join always becomes Hash Match — consider making the Hash Match explicit. If actualRows is consistently below the threshold, the join always uses Nested Loops — consider removing the adaptive join overhead with a LOOP JOIN hint. If actualRows straddles 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 has actualRows > 100,000 — actual plan only; skip entirely if StatisticsInfo elements 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, add PERSIST_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 from StatisticsInfo/@Statistics and @Table in the plan XML. If the table is large and FULLSCAN is too slow, use WITH SAMPLE 30 PERCENT, PERSIST_SAMPLE_PERCENT = ON as a compromise. Cross-reference N21 — if actualRows already diverges from estimateRows, 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.
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-reference references/check-explanations.md for deeper fix options. - Use schema-qualified names in Observed lines when the plan XML includes a
Schema attribute 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, or Schema.Table in prose. Example: dbo.Orders not Orders; [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 ParameterRunti