Key Lookup Elimination
Analyzes the plan cache for key lookups and recommends adding INCLUDE columns to eliminate them. Catches performance problems that the missing index DMVs completely miss.
What Is a Key Lookup?
A key lookup (also called a bookmark lookup) occurs when the query optimizer:
- Seeks a nonclustered index to find the matching rows efficiently
- Then goes back to the clustered index (or heap RID) for each row to retrieve additional columns that aren't in the nonclustered index
This means every qualifying row requires two I/O operations instead of one. For a query returning 10,000 rows, that's 10,000 extra random reads against the clustered index.
Why This Matters
Key lookups are invisible overhead. The query works — it uses an index, it returns correct results. But it's doing twice the I/O it needs to. On a query that runs 50,000 times a day with 20 lookups per execution, that's 1 million unnecessary random reads per day against your clustered index.
SQL Server's missing index DMVs will never flag this. The DMVs only report when no suitable index exists at all. If the nonclustered index exists and the optimizer uses it, the DMVs consider the problem solved — even though adding two INCLUDE columns would cut the I/O in half.
How It Detects Key Lookups
Plan Cache Scanning
The tool makes a single server-wide pass through the plan cache using sys.dm_exec_query_stats joined to sys.dm_exec_query_plan(). This captures every actively-used execution plan with its accumulated execution counts since the last service restart.
Why the Plan Cache, Not Query Store?
Query Store retention is often too short and execution counts too low for this analysis. The plan cache holds actively-used plans with real accumulated execution counts — the plans that matter right now. If a plan is in the cache, it's being used.
XQuery Detection
The showplan XML represents key lookups as IndexScan[@Lookup="1"], not as LogicalOp="Key Lookup". The "Key Lookup" label only appears in SSMS's graphical plan display. The proc uses .exist() with the showplan XML namespace for reliable detection:
-- This is how the proc finds key lookups in plan XML
-- The string 'Key Lookup' does NOT appear in the raw XML
query_plan.exist('
declare namespace p = "http://schemas.microsoft.com/sqlserver/2004/07/showplan";
//p:IndexScan[@Lookup="1"]
') = 1
Database Identification
The database name is extracted from the plan XML's Object/@Database attribute on the nonclustered index seek node — not from sys.dm_exec_query_plan.dbid, which is unreliable for parameterized queries and stored procedures that cross database boundaries.
Analysis Pipeline
sp_KeyLookupRecommendations(@RunAnalysis = 1)
|
+--> sp_KeyLookupAnalysis(@DatabaseName, @MinExecutions)
| |
| +--> Step 1: Clear unresolved results (preserve resolved history)
| |
| +--> Step 2: Collect top plans from plan cache
| | - sys.dm_exec_query_stats ranked by total_logical_reads
| | - Filter: only plans containing IndexScan[@Lookup="1"]
| | - One server-wide pass (no per-database loop)
| |
| +--> Step 3: XQuery extraction per plan
| | - From each Nested Loops node:
| | - Database: NC index seek's Object/@Database
| | - NC Index: IndexScan[not(@Lookup="1")]/Object/@Index
| | - Lookup Cols: IndexScan[@Lookup="1"]/OutputList/ColumnReference
| |
| +--> Step 4: Filter to target databases
| |
| +--> Step 5: Consolidate
| | - GROUP BY database + table + NC index
| | - UNION all lookup columns across plans
| | - SUM execution stats (reads, executions, plan count)
| |
| +--> Step 6: Post-process per result (dynamic SQL per database)
| - Query sys.indexes + sys.index_columns for current definition
| - Filter out columns already in NC index keys or includes
| - Calculate current size from sys.dm_db_partition_stats
| - Sample AVG(DATALENGTH) for each new column (10K rows)
| - Generate index name: IX_Table_Keys[_INCLUDE|_INCLUDES]
| - Generate CREATE + DROP statements
| - Delete results where all lookups already covered
|
+--> Generate ScriptText ranked by total logical reads
Naming Convention
Generated index names follow a strict convention: IX_TableName_KeyColumns[_INCLUDE|_INCLUDES]
| Scenario | Action | Example |
|---|---|---|
| New name matches existing index name | CREATE INDEX ... WITH (DROP_EXISTING = ON) | Atomic in-place replacement, zero downtime |
| New name differs from existing | CREATE INDEX [new_name] then DROP INDEX [old_name] | Comment notes the old name for traceability |
| Name exceeds 128 characters | Intelligent truncation with _xMORE suffix | SQL Server's identifier limit handled automatically |
Size Estimation
The tool uses proportional scaling with actual data sampling — not max_length guesses.
How It Works
- Get actual average row size from
sys.dm_db_index_physical_stats(SAMPLED mode) for the existing nonclustered index - Sample each new column being added: loops through each recommended INCLUDE column and runs
AVG(DATALENGTH(column))on 10,000 rows from the actual table - Calculate proportionally:
new_size = current_size × (avg_row + avg_new_bytes) / avg_row
Accuracy Safeguards
- Floor enforcement: The estimate is never less than the current index size + 5%. Adding columns can never shrink an index.
- Real data, not metadata: Previous versions used
max_length / 4to guess average column width, which wildly overestimated for nullable NVARCHAR columns. The v2.0 sampling approach eliminated 10x-100x overestimates. - Display in MB: All sizes shown in MB for consistency with existing index size display.
Example
Existing index: 178.5 MB with keys [step_id], [packet_id] and includes [completion_time], [user_id], [user_status].
Adding include: [completion_date] (datetime, 8 bytes average).
avg_row = 42 bytes, avg_new = 8 bytes. Estimate: 178.5 × (42 + 8) / 42 = 212.5 MB.
Actual after creation: 207.3 MB. Within 3% of estimate.
Enterprise Features
ONLINE = ON
On Enterprise and Developer editions, all generated CREATE INDEX statements include WITH (ONLINE = ON). This allows index creation without blocking concurrent queries — critical for production systems.
@RunnableUnder: Edition-Aware Thresholds
The @RunnableUnder parameter supports split thresholds for Standard vs Enterprise:
| Syntax | Standard Threshold | Enterprise Threshold |
|---|---|---|
NULL | All recommendations uncommented (default) | |
'2' | 2 GB | 2 GB |
'1,5e' | 1 GB | 5 GB (Enterprise can do ONLINE rebuilds) |
Indexes estimated above the active threshold are wrapped in /* */ comments for manual DBA review. The UPDATE ResolvedDate statement is also commented out so it doesn't mark the finding as resolved until the DBA actually runs it.
Parameters
sp_KeyLookupAnalysis (analysis engine)
| Parameter | Default | Description |
|---|---|---|
@DatabaseName | NULL | NULL = all user databases. Single database name or LIKE pattern. |
@MinExecutions | 10 | Minimum plan execution count. Filters out rarely-run queries that wouldn't benefit from index changes. |
@TopPlans | 200 | Maximum plans to analyze from the cache, ranked by total logical reads. Higher = more thorough but slower. |
sp_KeyLookupRecommendations (reporting)
| Parameter | Default | Description |
|---|---|---|
@RunAnalysis | 0 | BIT. When 1, runs sp_KeyLookupAnalysis first to refresh results. |
@DatabaseName | NULL | NULL = all databases. Supports CSV list and LIKE wildcards. |
@TableName | NULL | Filter to a specific table. NULL = all tables. |
@RunnableUnder | NULL | NULL = all uncommented. '2' = 2GB both editions. '1,5e' = 1GB Standard / 5GB Enterprise. |
@MinExecutions | 10 | Passed through to sp_KeyLookupAnalysis when @RunAnalysis = 1. |
Results Table: indexing.KeyLookupResults
One row per nonclustered index that has key lookups. ResolvedDate is set when the recommendation is applied.
| Column | Description |
|---|---|
KeyLookupId | Primary key (identity) |
DatabaseName, SchemaName, TableName | Location of the table |
NCIndexName | The nonclustered index triggering the key lookup |
NCIndexKeys | Current key columns of the NC index |
NCIndexIncludes | Current include columns of the NC index |
LookupColumns | Columns the queries need that aren't in the index (raw from plan cache) |
RecommendedIncludes | Columns to add (LookupColumns minus any already in the index) |
TotalExecutions | Cumulative execution count across all plans with this lookup |
TotalLogicalReads | Cumulative logical reads from the key lookup operations |
AvgLogicalReads | Average logical reads per execution |
PlanCount | Number of distinct cached plans that trigger this lookup |
NCIndexSizeKB | Current nonclustered index size in KB |
EstimatedSizeGB | Estimated new size after adding includes (stored in GB, displayed in MB) |
CreateStatement | Ready-to-run CREATE INDEX with proper naming and DROP of old index |
SampleQueryText | Example query text from one of the plans that triggers the lookup |
ResolvedDate | Set when the recommendation is applied; NULL = unresolved |
Output Script Features
Each recommendation in the generated script includes:
- Current index definition (keys + includes + size in MB)
- Columns to add and estimated new size
- Impact metrics: total reads, executions, avg reads/exec, plan count
EXEC sp_helpIndexUsagefor manual reviewONLINE = ONon Enterprise/Developer editions/* */commenting for indexes exceeding@RunnableUnderthresholdUPDATE ResolvedDateafter each fix for history tracking
Usage Examples
-- Analyze all databases, then view recommendations
EXEC indexing.sp_KeyLookupRecommendations @RunAnalysis = 1;
-- Analyze a specific database group
EXEC indexing.sp_KeyLookupRecommendations
@RunAnalysis = 1,
@DatabaseName = 'MyApp_%',
@RunnableUnder = '1,5e',
@MinExecutions = 1000;
-- View existing results without re-analyzing
EXEC indexing.sp_KeyLookupRecommendations;
-- Filter to a specific table
EXEC indexing.sp_KeyLookupRecommendations
@DatabaseName = 'MyDatabase',
@TableName = 'audit_trail';
-- Check the results table directly
SELECT DatabaseName, TableName, NCIndexName,
RecommendedIncludes, TotalLogicalReads, PlanCount
FROM indexing.KeyLookupResults
WHERE ResolvedDate IS NULL
ORDER BY TotalLogicalReads DESC;
-- History: what's been resolved
SELECT DatabaseName, TableName, NCIndexName, ResolvedDate
FROM indexing.KeyLookupResults
WHERE ResolvedDate IS NOT NULL
ORDER BY ResolvedDate DESC;