| name | sqlserver-indexes |
| description | SQL Server index management — find missing indexes, analyze fragmentation, identify unused indexes, design covering indexes, and understand index usage stats. |
SQL Server Indexes
Indexes are the single biggest lever for SQL Server query performance. This skill covers finding missing indexes, maintaining existing ones, eliminating unused indexes, and designing effective covering indexes.
1. Missing Indexes
SQL Server tracks index opportunities it identified during query execution in DMVs. These are not perfect recommendations — treat them as leads.
Script: ../scripts/missing-indexes.sql
SELECT TOP 20
ROUND(migs.avg_user_impact * (migs.user_seeks + migs.user_scans), 0)
AS impact_score,
DB_NAME(mid.database_id) AS database_name,
mid.statement AS table_name,
mid.equality_columns,
mid.inequality_columns,
mid.included_columns,
migs.user_seeks,
migs.user_scans,
migs.avg_user_impact,
'CREATE INDEX [IX_' + OBJECT_NAME(mid.object_id) + '_'
+ REPLACE(REPLACE(REPLACE(
ISNULL(mid.equality_columns, '') +
ISNULL('_' + mid.inequality_columns, ''),
'[', ''), ']', ''), ', ', '_')
+ '] ON ' + mid.statement
+ ' (' + ISNULL(mid.equality_columns, '')
+ CASE WHEN mid.inequality_columns IS NOT NULL
THEN CASE WHEN mid.equality_columns IS NOT NULL
THEN ', ' ELSE '' END + mid.inequality_columns
ELSE '' END + ')'
+ CASE WHEN mid.included_columns IS NOT NULL
THEN ' INCLUDE (' + mid.included_columns + ')'
ELSE '' END AS suggested_create_index
FROM sys.dm_db_missing_index_details mid
JOIN sys.dm_db_missing_index_groups mig ON mid.index_handle = mig.index_handle
JOIN sys.dm_db_missing_index_group_stats migs ON mig.index_group_handle = migs.group_handle
WHERE mid.database_id = DB_ID()
ORDER BY impact_score DESC;
Interpreting results:
impact_score = avg_user_impact × (seeks + scans) — higher is more important
equality_columns → these become the leading key columns
inequality_columns → these follow equality columns in the key (range predicates)
included_columns → INCLUDE these to cover the SELECT list (no key lookup needed)
- Don't blindly create every suggestion — consolidate similar indexes first
Column ordering rule: Always put equality predicates first, then inequality predicates, then included columns. See references/index-strategies.md.
2. Index Fragmentation
Fragmentation degrades performance by forcing SQL Server to read more pages for the same data. Run this regularly (monthly for most workloads).
Script: ../scripts/index-fragmentation.sql
SELECT
s.name AS schema_name,
o.name AS table_name,
i.name AS index_name,
ips.index_type_desc AS type_desc,
ROUND(ips.avg_fragmentation_in_percent, 1) AS fragmentation_pct,
ips.page_count,
CASE
WHEN ips.avg_fragmentation_in_percent > 30 THEN 'REBUILD'
WHEN ips.avg_fragmentation_in_percent > 10 THEN 'REORGANIZE'
ELSE 'SKIP'
END AS recommended_action,
'ALTER INDEX [' + i.name + '] ON [' + s.name + '].[' + o.name + '] '
+ CASE
WHEN ips.avg_fragmentation_in_percent > 30 THEN 'REBUILD WITH (ONLINE = ON);'
ELSE 'REORGANIZE;'
END AS maintenance_statement
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'SAMPLED') ips
sys.indexes i ips.object_id i.object_id ips.index_id i.index_id
sys.objects o i.object_id o.object_id
sys.schemas s o.schema_id s.schema_id
ips.avg_fragmentation_in_percent
ips.page_count
i.name
ips.avg_fragmentation_in_percent ;
Decision guide:
| Fragmentation | Action | Why |
|---|
| < 10% | Skip | Not worth the overhead |
| 10–30% | REORGANIZE | Online, incremental, low overhead |
| > 30% | REBUILD | More thorough; use ONLINE = ON on Enterprise Edition |
Notes:
REBUILD updates statistics automatically; REORGANIZE does not (run UPDATE STATISTICS separately)
ONLINE = ON requires Enterprise Edition — table stays accessible during rebuild
- Use
SAMPLED mode (not DETAILED) for DMV query — much faster on large tables
3. Unused Indexes
Indexes that are never read but still incur write overhead on every INSERT/UPDATE/DELETE. These are pure waste.
Script: ../scripts/unused-indexes.sql
SELECT
s.name AS schema_name,
o.name AS table_name,
i.name AS index_name,
i.type_desc,
ius.user_seeks,
ius.user_scans,
ius.user_lookups,
ius.user_updates,
ius.last_user_seek,
ius.last_user_scan,
ius.last_user_lookup,
'-- DROP INDEX [' + i.name + '] ON [' + s.name + '].[' + o.name + '];'
AS drop_statement
FROM sys.dm_db_index_usage_stats ius
JOIN sys.indexes i ON ius.object_id = i.object_id AND ius.index_id = i.index_id
JOIN sys.objects o ON i.object_id = o.object_id
JOIN sys.schemas s ON o.schema_id = s.schema_id
WHERE ius.database_id = DB_ID()
AND ius.user_seeks = 0
AND ius.user_scans = 0
AND ius.user_lookups = 0
AND ius.user_updates > 0
AND i.type_desc != 'CLUSTERED'
o.is_ms_shipped
ius.user_updates ;
⚠️ Important caveats:
- Usage stats reset on SQL Server service restart — don't use this query after a recent restart
- An index may be used by infrequent batch jobs — verify against monthly patterns before dropping
- Some indexes exist for constraint enforcement (UNIQUE) — check
i.is_unique_constraint
- Test dropping in dev/staging first
4. Index Usage Stats
Understand how an index is actually being used:
SELECT
s.name AS schema_name,
o.name AS table_name,
i.name AS index_name,
i.type_desc,
ius.user_seeks,
ius.user_scans,
ius.user_lookups,
ius.user_updates,
ius.last_user_seek,
ius.last_user_scan
FROM sys.dm_db_index_usage_stats ius
JOIN sys.indexes i ON ius.object_id = i.object_id AND ius.index_id = i.index_id
JOIN sys.objects o ON i.object_id = o.object_id
JOIN sys.schemas s ON o.schema_id = s.schema_id
WHERE ius.database_id = DB_ID()
AND o.is_ms_shipped = 0
ORDER BY ius.user_seeks + ius.user_scans + ius.user_lookups DESC;
Seeks vs Scans vs Lookups:
- Seeks — Best. SQL Server navigated directly to specific rows via the B-tree key.
- Scans — SQL Server read the whole index (or a large range). May indicate missing predicate or wrong index.
- Lookups — SQL Server found a row in the non-clustered index but had to go back to the clustered index for additional columns. Fix with a covering index.
5. Covering Indexes
A covering index satisfies a query entirely from the index — no lookup to the base table needed.
Problem pattern (Key Lookup):
SELECT OrderDate, Status, TotalAmount
FROM Orders
WHERE CustomerID = 42;
Fix — covering index:
CREATE INDEX IX_Orders_CustomerID_Covering
ON Orders (CustomerID)
INCLUDE (OrderDate, Status, TotalAmount);
Key vs INCLUDE columns:
- Key columns — used in WHERE, JOIN, ORDER BY. Added to the B-tree structure.
- INCLUDE columns — only carried in the leaf level. Can be any data type. Not searchable. Used to satisfy SELECT list.
See references/index-strategies.md for detailed design patterns.
Reference
- Index Strategies Reference — Composite key ordering, filtered indexes, columnstore, maintenance scheduling
- Scripts:
../scripts/missing-indexes.sql, ../scripts/index-fragmentation.sql, ../scripts/unused-indexes.sql