Column Access Pattern Analysis
The discovery tool in the suite. While the other tools react to what SQL Server tells you — missing index DMVs, unused indexes, expensive scans — this tool proactively analyzes the workload to find what indexes should exist based on how the data is actually accessed.
Why this matters: SQL Server's missing index DMVs only fire when a single query triggers the optimizer's threshold. A column filtered in 50 different queries — each too small to trigger a DMV suggestion individually — is a stronger index candidate than one appearing in 1 expensive query. This tool finds those hidden patterns by aggregating across the entire plan cache.
How It Differs from Missing Index DMVs
| Aspect | Missing Index DMVs | Column Access Pattern Analysis |
|---|---|---|
| Scope | Per-query: one suggestion per query | Workload-level: aggregates across all queries |
| Trigger | Optimizer decides a query would benefit | Statistical analysis of actual column usage |
| Composite indexes | Suggests per-query, often overlapping | Discovers columns that frequently appear together across many queries |
| Low-impact patterns | Missed (below optimizer threshold) | Found (aggregate impact across 50+ queries can be huge) |
| Existing index awareness | None | Checks every column against existing indexes before recommending |
Data Flow
sp_ColumnAccessPatternRecommendations(@RunAnalysis = 1)
|
+--> sp_ColumnAccessPatternAnalysis
| 1. Collect top N plans by total reads from plan cache
| 2. Extract predicate columns from scan/seek ops via XQuery
| 3. Extract columns from missing index hints (equality/inequality/include)
| 4. Filter to target databases
| 5. Aggregate by database + table + column + usage type
| - Count distinct queries referencing each column
| - Sum total reads/executions
| 6. Check existing index coverage per column (per-database loop)
| - LEADING_KEY, KEY, INCLUDED, or NULL (not indexed)
| 7. Set suggested action based on coverage gap
| -> writes indexing.ColumnAccessPatternResults
|
+--> Build consolidated per-table recommendations:
| - Aggregate key columns (EQUALITY/INEQUALITY/SEEK/SCAN predicates)
| - Aggregate include columns
| - Check existing indexes for near-matches (EXTEND vs CREATE)
| - Generate CREATE INDEX or DROP_EXISTING for EXTEND
| - Estimate size via sp_GetHybridIndexSizeEstimate
|
+--> Generate ScriptText output grouped by database and table
Column Classification
The analysis extracts column references from the plan cache XML using XQuery, categorizing each by how the query uses it:
| Usage Type | Source in Plan XML | Index Implication |
|---|---|---|
EQUALITY | Missing index hint: ColumnGroup[@Usage="EQUALITY"] | Strong key column candidate (leftmost position) |
INEQUALITY | Missing index hint: ColumnGroup[@Usage="INEQUALITY"] | Key column candidate (after equality columns) |
SEEK_PREDICATE | Index seek operation: SeekPredicates/SeekKeys | Already used as seek key — confirms importance |
SCAN_PREDICATE | Scan/seek residual predicate: Predicate/ScalarOperator | Filtered during scan — would benefit from being a key |
INCLUDE | Missing index hint: ColumnGroup[@Usage="INCLUDE"] | Include column candidate (output only, not filtered) |
Columns used as EQUALITY or SEEK_PREDICATE across many queries become key columns. Columns used as INCLUDE stay as includes. The tool orders keys by frequency — the most commonly filtered column becomes the leading key.
Existing Index Coverage Check
Before recommending anything, each discovered column is checked against the server's existing indexes:
| Coverage Level | Meaning | Action |
|---|---|---|
LEADING_KEY | Column is the first key in an existing index | No action needed (well-indexed) |
KEY | Column is a non-leading key in an existing index | Usually sufficient, but may benefit from reordering |
INCLUDED | Column is in an INCLUDE list | Consider promoting to key if used in WHERE/JOIN |
NULL | Column is not in any index | Strong candidate for a new or extended index |
By default, columns with LEADING_KEY coverage are hidden from output. Use @ShowCovered = 1 to see the full picture.
Near-Match Detection & Consolidation
When multiple columns on the same table need indexing, the tool consolidates them into a single recommendation rather than suggesting separate indexes for each column. It then checks existing indexes for near-matches:
- EXTEND: An existing index has the right leading key(s) but is missing some columns. The tool generates
CREATE INDEX ... WITH (DROP_EXISTING = ON)to atomically replace it with a wider version. - CREATE: No existing index is close enough. A new index is generated with a proper name following the
IX_Table_Keys[_INCLUDES]convention.
This prevents index proliferation — instead of 5 new indexes on one table, you might get 1 new index and 2 extensions of existing ones.
Results Table
indexing.ColumnAccessPatternResults — one row per database + table + column + usage type:
| Column | Description |
|---|---|
DatabaseName, SchemaName, TableName | The table being analyzed |
ColumnName | The column being accessed |
UsageType | EQUALITY, INEQUALITY, SEEK_PREDICATE, SCAN_PREDICATE, INCLUDE |
QueryCount | Distinct queries referencing this column in this way |
TotalExecutions | Aggregate execution count across all referencing queries |
TotalLogicalReads | Aggregate logical reads across all referencing queries |
ExistingIndexCoverage | LEADING_KEY, KEY, INCLUDED, or NULL |
SuggestedAction | Recommended action based on coverage gap |
EstimatedSizeGB | Estimated index size if a new index is recommended |
CreateStatement | Ready-to-run CREATE INDEX statement |
ResolvedDate | Set when the recommendation is reviewed/applied |
Parameters
sp_ColumnAccessPatternRecommendations (reporting)
| Parameter | Default | Description |
|---|---|---|
@RunAnalysis | 0 | When 1, runs sp_ColumnAccessPatternAnalysis first to refresh data |
@DatabaseName | NULL | NULL = all user databases. Supports CSV list and LIKE wildcards |
@TableName | NULL | Filter to one table. NULL = all |
@RunnableUnder | NULL | NULL = all uncommented. '2' = 2GB both. '1,5e' = 1GB Std / 5GB Ent |
@TopN | 200 | Max plans to analyze from the plan cache. Higher = more thorough but slower |
@MinQueryCount | 3 | Minimum distinct queries referencing a column to include it. Filters one-off patterns |
@ShowCovered | 0 | When 1, includes columns already well-indexed (LEADING_KEY) |
sp_ColumnAccessPatternAnalysis (engine)
| Parameter | Default | Description |
|---|---|---|
@DatabaseName | NULL | Target database(s). NULL = all user databases |
@TopN | 200 | Top plans by total logical reads to analyze |
@MinQueryCount | 3 | Column must appear in at least this many distinct queries |
Script Output Features
- Column access summary: Top 10 most-accessed columns per table with coverage tags
- Consolidated recommendation: Key columns + includes derived from workload patterns
- Near-match detection: EXTEND existing index with
DROP_EXISTING = ON, or CREATE new - Estimated size: Via hybrid size estimator (displayed in MB)
- Enterprise detection:
WITH (ONLINE = ON)on Enterprise/Developer editions - @RunnableUnder: Large indexes wrapped in
/* */with edition-aware thresholds - ResolvedDate tracking:
UPDATEstatement per table for history - Smart filtering: Tables with no actionable recommendations are hidden; empty database headers are suppressed
Usage Examples
-- Default: analyze top 200 plans, show columns in 3+ queries
EXEC indexing.sp_ColumnAccessPatternRecommendations @RunAnalysis = 1;
-- Broader analysis: top 1000 plans across all databases
EXEC indexing.sp_ColumnAccessPatternRecommendations
@RunAnalysis = 1,
@DatabaseName = 'MyApp_%',
@RunnableUnder = '1,5e',
@TopN = 1000;
-- Stricter: only columns in 10+ queries (high-confidence patterns)
EXEC indexing.sp_ColumnAccessPatternRecommendations
@RunAnalysis = 1,
@MinQueryCount = 10;
-- Show everything including well-indexed columns (audit view)
EXEC indexing.sp_ColumnAccessPatternRecommendations
@RunAnalysis = 1,
@ShowCovered = 1;
-- Focus on a specific table
EXEC indexing.sp_ColumnAccessPatternRecommendations
@TableName = 'audit_trail';
-- View raw analysis data
SELECT DatabaseName, TableName, ColumnName, UsageType,
QueryCount, TotalLogicalReads, ExistingIndexCoverage
FROM indexing.ColumnAccessPatternResults
WHERE ResolvedDate IS NULL
ORDER BY TotalLogicalReads DESC;