Expensive Query Plan Analysis
Analyzes the plan cache (and optionally Query Store) for the most expensive queries and identifies optimization opportunities. This complements the other indexing tools — duplicate-index, key-lookup, and unused-index find problems with existing indexes. This tool finds problems with queries — specifically, the queries that would benefit most from new or better indexes.
Key distinction: The Missing Index tool (optimize-index) reads SQL Server's DMV-level suggestions. This tool reads the actual plan cache — it sees what the optimizer actually did with real queries: which tables got scanned, which plans have embedded missing index hints, and which queries regressed over time. It catches issues the DMVs miss because it works from executed query plans, not aggregated suggestions.
Finding Types
TABLE_SCAN
Identifies clustered index scans, heap scans, and index scans on large tables. These are operations where the optimizer chose to read a significant portion of a table because no suitable index exists for the query's predicates.
| Scan Type | What It Means |
|---|---|
CLUSTERED_SCAN | Full clustered index scan — reading every row in table order. The table has a clustered index but no nonclustered index to serve the query's WHERE/JOIN predicates. |
HEAP_SCAN | Full table scan on a heap (no clustered index). Every row is read in allocation order. |
INDEX_SCAN | Full nonclustered index scan — the optimizer is reading an entire nonclustered index because it's narrower than the clustered index but still lacks the right key columns for a seek. |
Only scans with ≥ 1,000 estimated rows are flagged. Small-table scans are normal and not worth optimizing.
MISSING_INDEX
The SQL Server optimizer embeds missing index hints directly in execution plans when it identifies that an index would have improved the query. These are more targeted than the DMV-level suggestions because they come from specific query plans with real execution statistics.
Only hints with impact ≥ @MinImpact (default 50%) are flagged. After collection, the tool:
- Deduplicates exact matches (same keys + includes on the same table)
- Consolidates overlapping recommendations (same keys, different includes → merge to widest include set)
- Validates columns against the target table schema (the optimizer can misattribute columns in multi-table joins)
- Generates scripts via
sp_BuildIndexScriptwhich checks existing indexes to determine the right strategy (CREATE / EXTEND / REBUILD / skip if covered) - Estimates size via the hybrid size estimator
REGRESSED_QUERY
Requires @WithQueryStore = 1. Compares recent query performance (last N days via @LookbackDays) against historical performance. Queries where recent average logical reads are ≥ 3x worse than historical are flagged.
This catches:
- Plan regressions — the optimizer picked a worse plan after a recompile
- Parameter sniffing — a plan compiled for atypical parameter values
- Data growth — a table grew large enough that the existing plan no longer works well
- Missing statistics — stale stats caused a bad plan choice
Analysis Engine
Data Flow
sp_ExpensiveQueryRecommendations(@RunAnalysis = 1)
|
+--> sp_ExpensiveQueryAnalysis
| Phase 1: Plan Cache (always runs)
| 1A. Collect top N queries from sys.dm_exec_query_stats
| ranked by @SortBy (READS, CPU, DURATION, EXECUTIONS)
| 1B. Parse plan XML via XQuery for scans
| (only scans with >= 1000 estimated rows)
| 1C. Parse plan XML for missing index hints
| (only hints with >= @MinImpact)
| 1D. Filter results to target databases
|
| Phase 2: Query Store regressions (when @WithQueryStore = 1)
| - Per database: compare recent vs historical avg reads
| - Flag queries where recent >= 3x worse
|
| -> writes indexing.ExpensiveQueryResults
|
+--> Post-processing (MISSING_INDEX findings):
| - Deduplicate exact matches
| - Consolidate overlapping (same keys, merge includes)
| - Validate columns exist on target table
| - Generate scripts via sp_BuildIndexScript
| - Estimate size via sp_GetHybridIndexSizeEstimate
| -> updates CreateStatement, EstimatedSizeGB
|
+--> Generate ScriptText output grouped by database -> table -> finding type
Server-Wide Single Pass
The plan cache analysis runs server-wide in a single pass — no per-database loop needed. The top N queries are collected from sys.dm_exec_query_stats, then their plan XML is parsed using XQuery with the showplan namespace. Database identification comes from the plan XML's Object/@Database attribute, and results are filtered to target databases afterward.
Column Validation
The optimizer can misattribute predicate columns to the wrong table in multi-table joins. sp_BuildIndexScript validates that all recommended key and include columns actually exist on the target table. Findings with invalid columns are flagged as SKIPPED rather than generating broken CREATE INDEX statements.
Query Store Integration
When @WithQueryStore = 1, the tool adds a second analysis phase that runs per-database (only databases with Query Store enabled). It compares two time windows:
| Window | Duration | Purpose |
|---|---|---|
| Recent | Last @LookbackDays days (default 7) | Current performance baseline |
| Historical | Everything before the recent window | What performance used to be |
A query is flagged as regressed when recent_avg_reads / historical_avg_reads ≥ 3.0. The regression factor is included in the output so you can prioritize the worst regressions.
Performance note: Query Store analysis is significantly slower than plan cache analysis because it queries each database individually. Use @WithQueryStore = 1 for targeted investigations, not routine fleet scans. The plan cache phase is fast and sufficient for most use cases.
Script Generation
For MISSING_INDEX findings, the tool generates ready-to-run CREATE INDEX statements using the same infrastructure as the rest of the suite:
- Existing index awareness —
sp_BuildIndexScriptchecks current indexes and determines CREATE (new), EXTEND (add includes to existing), REBUILD (re-enable disabled), or SUPERSET_COVERED (skip, already handled) - Naming convention —
IX_{table}_{keys}[_INCLUDES], with intelligent truncation at 128 chars - Enterprise detection —
WITH (ONLINE = ON)on Enterprise/Developer editions - Size estimation — via hybrid estimator with v2.0 data sampling
- @RunnableUnder — edition-aware thresholds; indexes above the threshold are wrapped in
/* */ - Cross-tool deduplication — when run via the orchestrator, findings already covered by Key Lookup or other tools are noted as "Covered above by..."
For TABLE_SCAN and REGRESSED_QUERY findings, no CREATE INDEX is generated — these are diagnostic findings that require manual investigation.
Parameters
sp_ExpensiveQueryRecommendations (reporting)
| Parameter | Default | Description |
|---|---|---|
@RunAnalysis | 0 | BIT. When 1, runs sp_ExpensiveQueryAnalysis first to refresh data. |
@DatabaseName | NULL | NULL = all user databases. Supports CSV list and LIKE wildcards. |
@TableName | NULL | Filter to one table. NULL = all. |
@TopN | 50 | Max top queries to analyze from the plan cache. Higher = more thorough but slower. |
@SortBy | 'READS' | Rank queries by: READS, CPU, DURATION, or EXECUTIONS. |
@MinExecutions | 10 | Minimum execution count to consider. Filters one-off queries. |
@MinImpact | 50.0 | Minimum optimizer impact % to flag a missing index hint. Higher = fewer, more impactful findings. |
@RunnableUnder | NULL | Edition-aware size threshold. NULL = all uncommented. '2' = 2GB both. '1,5e' = 1GB Std / 5GB Ent. |
@FindingType | NULL | Filter to one type: TABLE_SCAN, MISSING_INDEX, or REGRESSED_QUERY. NULL = all. |
@WithQueryStore | 0 | BIT. When 1, enables Query Store regression detection per database. Significantly slower. |
@LookbackDays | 7 | Days back for Query Store regression comparison. |
sp_ExpensiveQueryAnalysis (engine)
Called by the recommendations proc when @RunAnalysis = 1. Same parameters as above minus @RunnableUnder, @FindingType, and @TableName (those are reporting-only filters).
Results Table
indexing.ExpensiveQueryResults — one row per finding, with ResolvedDate tracking.
| Column | Description |
|---|---|
FindingType | TABLE_SCAN, MISSING_INDEX, or REGRESSED_QUERY |
SchemaName, TableName, IndexName | The object involved |
QueryHash | Identifies the query across plan recompilations |
TotalExecutions, TotalLogicalReads | Aggregate execution stats |
AvgLogicalReads, AvgCPUTimeMs, AvgDurationMs | Per-execution averages |
ScanType | CLUSTERED_SCAN, HEAP_SCAN, INDEX_SCAN (TABLE_SCAN findings) |
EstimatedRows | Optimizer's row estimate for the scan |
MissingIndexKeys, MissingIndexIncludes | Recommended key and include columns (MISSING_INDEX) |
MissingIndexImpact | Optimizer's estimated impact percentage |
RegressionFactor | How much worse recent vs historical (REGRESSED_QUERY) |
SampleQueryText | First 4000 chars of the query |
CreateStatement | Ready-to-run CREATE INDEX (when applicable) |
EstimatedSizeGB | Estimated new index size (displayed as MB in output) |
DataSource | PLAN_CACHE or QUERY_STORE |
ResolvedDate | Set when the recommendation is applied |
Usage Examples
-- Default: analyze plan cache for scans and missing indexes
EXEC indexing.sp_ExpensiveQueryRecommendations @RunAnalysis = 1;
-- Single database with high thresholds
EXEC indexing.sp_ExpensiveQueryRecommendations
@RunAnalysis = 1,
@DatabaseName = 'MyDatabase',
@TopN = 100,
@MinImpact = 90;
-- Fleet-wide: top 1000 queries, edition-aware threshold
EXEC indexing.sp_ExpensiveQueryRecommendations
@RunAnalysis = 1,
@DatabaseName = 'MyApp_%',
@TopN = 1000,
@MinExecutions = 500,
@RunnableUnder = '1,5e';
-- Sort by CPU instead of reads
EXEC indexing.sp_ExpensiveQueryRecommendations
@RunAnalysis = 1,
@SortBy = 'CPU';
-- Include Query Store regression detection
EXEC indexing.sp_ExpensiveQueryRecommendations
@RunAnalysis = 1,
@WithQueryStore = 1,
@LookbackDays = 14;
-- Only regressed queries
EXEC indexing.sp_ExpensiveQueryRecommendations
@FindingType = 'REGRESSED_QUERY';
-- Only missing index findings, lower impact threshold
EXEC indexing.sp_ExpensiveQueryRecommendations
@RunAnalysis = 1,
@FindingType = 'MISSING_INDEX',
@MinImpact = 30.0;
-- View existing results without re-analyzing
EXEC indexing.sp_ExpensiveQueryRecommendations;