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:

  1. Seeks a nonclustered index to find the matching rows efficiently
  2. 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]

ScenarioActionExample
New name matches existing index nameCREATE INDEX ... WITH (DROP_EXISTING = ON)Atomic in-place replacement, zero downtime
New name differs from existingCREATE INDEX [new_name] then DROP INDEX [old_name]Comment notes the old name for traceability
Name exceeds 128 charactersIntelligent truncation with _xMORE suffixSQL Server's identifier limit handled automatically

Size Estimation

The tool uses proportional scaling with actual data sampling — not max_length guesses.

How It Works

  1. Get actual average row size from sys.dm_db_index_physical_stats (SAMPLED mode) for the existing nonclustered index
  2. 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
  3. Calculate proportionally:
new_size = current_size × (avg_row + avg_new_bytes) / avg_row

Accuracy Safeguards

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:

SyntaxStandard ThresholdEnterprise Threshold
NULLAll recommendations uncommented (default)
'2'2 GB2 GB
'1,5e'1 GB5 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)

ParameterDefaultDescription
@DatabaseNameNULLNULL = all user databases. Single database name or LIKE pattern.
@MinExecutions10Minimum plan execution count. Filters out rarely-run queries that wouldn't benefit from index changes.
@TopPlans200Maximum plans to analyze from the cache, ranked by total logical reads. Higher = more thorough but slower.

sp_KeyLookupRecommendations (reporting)

ParameterDefaultDescription
@RunAnalysis0BIT. When 1, runs sp_KeyLookupAnalysis first to refresh results.
@DatabaseNameNULLNULL = all databases. Supports CSV list and LIKE wildcards.
@TableNameNULLFilter to a specific table. NULL = all tables.
@RunnableUnderNULLNULL = all uncommented. '2' = 2GB both editions. '1,5e' = 1GB Standard / 5GB Enterprise.
@MinExecutions10Passed 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.

ColumnDescription
KeyLookupIdPrimary key (identity)
DatabaseName, SchemaName, TableNameLocation of the table
NCIndexNameThe nonclustered index triggering the key lookup
NCIndexKeysCurrent key columns of the NC index
NCIndexIncludesCurrent include columns of the NC index
LookupColumnsColumns the queries need that aren't in the index (raw from plan cache)
RecommendedIncludesColumns to add (LookupColumns minus any already in the index)
TotalExecutionsCumulative execution count across all plans with this lookup
TotalLogicalReadsCumulative logical reads from the key lookup operations
AvgLogicalReadsAverage logical reads per execution
PlanCountNumber of distinct cached plans that trigger this lookup
NCIndexSizeKBCurrent nonclustered index size in KB
EstimatedSizeGBEstimated new size after adding includes (stored in GB, displayed in MB)
CreateStatementReady-to-run CREATE INDEX with proper naming and DROP of old index
SampleQueryTextExample query text from one of the plans that triggers the lookup
ResolvedDateSet when the recommendation is applied; NULL = unresolved

Output Script Features

Each recommendation in the generated script includes:

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;