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:

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:

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:

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:

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

ParameterDefaultDescription
@RunAnalysis0BIT. When 1, collects live index data from DMVs and refreshes results before generating output. When 0, uses existing results from a prior analysis.
@DatabaseNameNULLNULL = all user databases. Supports comma-delimited list ('DB1,DB2') and LIKE wildcards ('MyApp_%').
@RunnableUnderNULLNULL = all uncommented. '2' = 2 GB both editions. '1,5e' = 1 GB Standard / 5 GB Enterprise. Only affects COMBINE operations.
@TableNameNULLFilter to a specific table. NULL = all tables.
@ActionTypeNULLFilter to 'DUPLICATE', 'REDUNDANT', 'DUPLICATE of PK', or 'COMBINE'. NULL = all types.

Results Table

indexing.IndexOptimizationResults — one row per index pair finding.

ColumnDescription
IndexOptimizationIdPrimary key (identity)
ServerNameServer where the analysis ran
DatabaseName, SchemaName, TableNameLocation of the indexes
ActionTypeDUPLICATE, REDUNDANT, DUPLICATE of PK, or COMBINE
Index1Id, Index1Name, Index1Keys, Index1IncludesFirst index (the one to DROP)
Index2Id, Index2Name, Index2Keys, Index2IncludesSecond index (KEEP for DUP/REDUNDANT, DROP for COMBINE)
Index1SizeKB, Index1UserSeeks, Index1UserScansSize and DMV usage stats for Index 1
Index2SizeKB, Index2UserSeeks, Index2UserScansSize and DMV usage stats for Index 2
NewIndexName, NewIndexKeys, NewIncludeColumnsCOMBINE only: the merged index definition
EstimatedSizeGBCOMBINE only: estimated size of the new index (displayed in MB)
CreateStatement, DropStatement1, DropStatement2Ready-to-run SQL statements
ResolvedDateNULL = unresolved. Set to GETDATE() when the recommendation is applied.
ProcessedDateWhen 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;