Duplicate Index Detection & Optimization
Detects duplicate, redundant, and combinable nonclustered indexes across all databases on a server. Collects live index metadata via DMV queries (one fast query per database), analyzes column overlap, and generates ready-to-execute DROP and CREATE scripts with usage statistics and size estimates.
Why This Matters
Duplicate and redundant indexes are one of the most common performance drains in SQL Server, yet they're rarely cleaned up because they're hard to spot manually. Every unnecessary index:
- Increases write overhead — every INSERT, UPDATE, and DELETE must maintain every index on the table. A table with 15 indexes does 15x the index write work of a table with one. On high-write tables, this directly impacts transaction throughput.
- Wastes storage — a redundant 2 GB index on 50 servers is 100 GB of wasted disk across the fleet, plus backup time and restore time.
- Confuses the optimizer — SQL Server's query optimizer may choose a suboptimal index when multiple overlapping indexes exist, especially when statistics differ between them.
- Slows maintenance — every extra index extends REBUILD and REORGANIZE times during maintenance windows.
This tool automates what would take hours of manual sys.indexes comparison into a sub-minute analysis with actionable output.
Detection Types
The tool classifies every finding into one of four action types, each with different risk profiles and output scripts:
DUPLICATE
Two nonclustered indexes on the same table with identical key columns (same order), where one has a superset of include columns. The narrower index is redundant — every seek/scan it can serve, the wider one handles equally well.
Example:
-- Index A (DROP): Keys: [customer_id, order_date], Includes: (none)
-- Index B (KEEP): Keys: [customer_id, order_date], Includes: [total_amount, status]
-- Index B covers everything Index A does, plus more.
Output: DROP INDEX on the narrower index. Always safe — no new index is created.
DUPLICATE of PK
A nonclustered index whose key columns exactly match the clustered index (primary key). Since every nonclustered index already contains the clustering key as its row locator, this nonclustered index is completely redundant — it's a full copy of data the clustered index already provides.
Example:
-- Clustered PK: Keys: [order_id]
-- NC Index (DROP): Keys: [order_id]
-- The NC index is a mirror of what the PK already provides.
Output: DROP INDEX on the nonclustered index.
REDUNDANT
One index's key columns are a leftmost prefix of another index's keys, and the shorter index's include columns are a subset of the longer one's. The shorter index is redundant because the longer index can serve any seek on the prefix columns.
Example:
-- Index A (DROP): Keys: [customer_id], Includes: [name]
-- Index B (KEEP): Keys: [customer_id, order_date], Includes: [name, total]
-- A seek on [customer_id] can use Index B (leftmost prefix).
-- Index A's include [name] is already in Index B.
Output: DROP INDEX on the shorter index. Usage stats are included in the comments so you can verify the shorter index wasn't the optimizer's preferred choice for a high-frequency query.
COMBINE
Two indexes on the same table where one's keys are a prefix of the other's, but their include columns aren't a subset relationship — each has includes the other lacks. Neither is fully redundant, but they could be replaced by a single wider index that covers both.
Example:
-- Index A: Keys: [customer_id], Includes: [email, phone]
-- Index B: Keys: [customer_id, order_date], Includes: [total_amount]
-- Neither covers the other.
-- Combined: Keys: [customer_id, order_date], Includes: [email, phone, total_amount]
-- One index replaces two, saving write overhead.
Output: CREATE INDEX (the merged index) + DROP INDEX for both originals. Includes an estimated size for the new combined index.
Risk: This is the only action type that creates a new index. The new index is wider (more includes), so it's larger. The size estimate helps assess whether the trade-off is worthwhile.
Analysis Engine
The core analysis is performed by indexing.IndexOptimization_Insert, a self-contained procedure that collects live data and analyzes in one pass:
Step 1: Live Index Collection
Loops through target databases. For each database, runs a single DMV query joining sys.indexes, sys.index_columns, sys.columns, and sys.data_spaces into a #ServerIndexes temp table. Sub-second per database. Handles collation differences with COLLATE DATABASE_DEFAULT to prevent cross-database comparison errors.
Step 2: Duplicate & Redundant Detection
Compares all active, non-filtered, non-disabled nonclustered indexes on each table. Uses column-set comparison to find:
- Exact key matches with superset includes (DUPLICATE)
- Key matches against the clustered index (DUPLICATE of PK)
- Leading prefix key matches with subset includes (REDUNDANT)
Deduplicates across categories so a single index only appears in one finding. Priority: DUPLICATE of PK > DUPLICATE > REDUNDANT.
Step 3: Combination Detection
For indexes not already flagged in step 2, finds pairs where one index's keys are a leading prefix of the other's. Merges their include columns (deduped, excluding columns already in the longer key set or the clustered key). Generates:
- A new index name following
IX_TableName_Keys[_INCLUDES]convention, with_NEWappended on name collisions - CREATE and DROP statements
- Handles DESC columns in keys (
(-)replaced withDESCin generated SQL)
Step 4: Size Estimation
After COMBINE inserts, calls sp_GetHybridIndexSizeEstimate per row to estimate the new combined index size. The hybrid estimator first tries structural similarity to existing indexes, then falls back to actual data sampling via AVG(DATALENGTH()). Result stored in EstimatedSizeGB, displayed in MB.
Step 5: Usage & Size Stats
After all inserts, loops through distinct databases in the results and queries sys.dm_db_partition_stats (sizes) and sys.dm_db_index_usage_stats (seeks, scans) for only the indexes in the results table. This populates the Index1/Index2 size and usage columns shown in the output comments.
Script Generation
The recommendation proc (sp_DuplicateIndexOptimizationRecommendations) generates a single ScriptText organized by database and table:
DUPLICATE / REDUNDANT / DUPLICATE of PK Output
/*****************************************************/
-- Table: [dbo].[orders]
/*****************************************************/
-- REDUNDANT
-- DROP: IX_orders_customer_id (Id: 5)
-- Key Columns: [customer_id]
-- Include Columns: (none)
-- Size: 45.2 MB | Seeks: 1,200 | Scans: 50
-- KEEP: IX_orders_customer_id_order_date (Id: 8)
-- Key Columns: [customer_id], [order_date]
-- Include Columns: [total_amount]
-- Size: 78.9 MB | Seeks: 8,500 | Scans: 200
-- EXEC sp_helpIndexUsage 'dbo.orders';
DROP INDEX IF EXISTS [IX_orders_customer_id] ON [dbo].[orders];
UPDATE DBA_Utilities.indexing.IndexOptimizationResults
SET ResolvedDate = GETDATE() WHERE IndexOptimizationId = 42;
Key details in the comments: both indexes' keys, includes, size, and DMV usage stats (seeks + scans). The sp_helpIndexUsage call lets you inspect column-level usage before committing.
COMBINE Output
-- COMBINE
-- Index 1: IX_orders_customer_id_INCLUDES (Id: 5)
-- Key Columns: [customer_id]
-- Include Columns: [email], [phone]
-- Size: 120.5 MB | Seeks: 5,000 | Scans: 100
-- Index 2: IX_orders_customer_id_order_date (Id: 8)
-- Key Columns: [customer_id], [order_date]
-- Include Columns: [total_amount]
-- Size: 78.9 MB | Seeks: 8,500 | Scans: 200
-- New Index: IX_orders_customer_id_order_date_INCLUDES
-- Key Columns: [customer_id], [order_date]
-- Include Columns: [email], [phone], [total_amount]
-- Estimated Size: ~210.3 MB
CREATE NONCLUSTERED INDEX [IX_orders_customer_id_order_date_INCLUDES]
ON [dbo].[orders] ([customer_id], [order_date])
INCLUDE ([email], [phone], [total_amount]);
DROP INDEX IF EXISTS [IX_orders_customer_id_INCLUDES] ON [dbo].[orders];
DROP INDEX IF EXISTS [IX_orders_customer_id_order_date] ON [dbo].[orders];
Enterprise Features
ONLINE = ON
On Enterprise and Developer editions, COMBINE operations automatically add WITH (ONLINE = ON) to the CREATE INDEX statement. This allows the index to be built without blocking concurrent queries — critical for 24/7 production databases.
@RunnableUnder
Controls auto-commenting of large COMBINE operations. Accepts the edition-aware syntax:
NULL— everything uncommented (default)'2'— COMBINE operations with estimated size ≥ 2 GB are wrapped in/* */'1,5e'— 1 GB threshold for Standard, 5 GB for Enterprise
Important: DROP-only operations (DUPLICATE, REDUNDANT, DUPLICATE of PK) are never commented out regardless of @RunnableUnder. Dropping an index is a metadata operation — it doesn't build anything, doesn't lock the table, and reclaims space immediately.
Filtered Index Protection
The analysis engine excludes all filtered indexes from comparison. A filtered index with a WHERE clause serves a specific purpose that column-set comparison cannot assess. The tool will never recommend dropping, combining, or modifying a filtered index.
Parameters
| Parameter | Default | Description |
|---|---|---|
@RunAnalysis | 0 | BIT. When 1, collects live index data from DMVs and refreshes results before generating output. When 0, uses existing results from a prior analysis. |
@DatabaseName | NULL | NULL = all user databases. Supports comma-delimited list ('DB1,DB2') and LIKE wildcards ('MyApp_%'). |
@RunnableUnder | NULL | NULL = all uncommented. '2' = 2 GB both editions. '1,5e' = 1 GB Standard / 5 GB Enterprise. Only affects COMBINE operations. |
@TableName | NULL | Filter to a specific table. NULL = all tables. |
@ActionType | NULL | Filter to 'DUPLICATE', 'REDUNDANT', 'DUPLICATE of PK', or 'COMBINE'. NULL = all types. |
Results Table
indexing.IndexOptimizationResults — one row per index pair finding.
| Column | Description |
|---|---|
IndexOptimizationId | Primary key (identity) |
ServerName | Server where the analysis ran |
DatabaseName, SchemaName, TableName | Location of the indexes |
ActionType | DUPLICATE, REDUNDANT, DUPLICATE of PK, or COMBINE |
Index1Id, Index1Name, Index1Keys, Index1Includes | First index (the one to DROP) |
Index2Id, Index2Name, Index2Keys, Index2Includes | Second index (KEEP for DUP/REDUNDANT, DROP for COMBINE) |
Index1SizeKB, Index1UserSeeks, Index1UserScans | Size and DMV usage stats for Index 1 |
Index2SizeKB, Index2UserSeeks, Index2UserScans | Size and DMV usage stats for Index 2 |
NewIndexName, NewIndexKeys, NewIncludeColumns | COMBINE only: the merged index definition |
EstimatedSizeGB | COMBINE only: estimated size of the new index (displayed in MB) |
CreateStatement, DropStatement1, DropStatement2 | Ready-to-run SQL statements |
ResolvedDate | NULL = unresolved. Set to GETDATE() when the recommendation is applied. |
ProcessedDate | When the analysis was last run |
Records are never deleted. The analysis engine only clears unresolved records before re-analyzing, preserving the full resolved history for audit and trending.
Usage Examples
-- Full analysis with live data, all databases
EXEC indexing.sp_DuplicateIndexOptimizationRecommendations
@RunAnalysis = 1;
-- Single database with edition-aware thresholds
EXEC indexing.sp_DuplicateIndexOptimizationRecommendations
@RunAnalysis = 1,
@DatabaseName = 'MyDatabase',
@RunnableUnder = '1,5e';
-- Wildcard: all app databases
EXEC indexing.sp_DuplicateIndexOptimizationRecommendations
@RunAnalysis = 1,
@DatabaseName = 'MyApp_%';
-- View existing results without re-analyzing
EXEC indexing.sp_DuplicateIndexOptimizationRecommendations;
-- Only COMBINE recommendations
EXEC indexing.sp_DuplicateIndexOptimizationRecommendations
@ActionType = 'COMBINE';
-- Only DUPLICATE/REDUNDANT (safe drops, no new indexes)
EXEC indexing.sp_DuplicateIndexOptimizationRecommendations
@ActionType = 'DUPLICATE';
EXEC indexing.sp_DuplicateIndexOptimizationRecommendations
@ActionType = 'REDUNDANT';
-- Query the results table directly for a fleet-wide view
SELECT ServerName, DatabaseName, TableName, ActionType,
Index1Name, Index2Name, Index1SizeKB/1024.0 AS Index1SizeMB
FROM indexing.IndexOptimizationResults
WHERE ResolvedDate IS NULL
ORDER BY Index1SizeKB DESC;