Unused Index Detection

Identifies nonclustered indexes with zero seeks, scans, and lookups but active write overhead. Uses DMV usage stats as the primary signal and Query Store plan references as optional confirmation. All objects in the indexing schema in DBA_Utilities.

Why Unused Indexes Matter

Every nonclustered index on a table is maintained on every INSERT, UPDATE, and DELETE — whether or not any query ever reads from it. An unused index is pure cost:

Why Detection Is Tricky

DMV stats reset on restart. sys.dm_db_index_usage_stats tracks seeks, scans, lookups, and updates — but only since the last SQL Server service restart. A server that rebooted yesterday has 1 day of data. An index that's only used for month-end reporting would look "unused" for 29 days of the month. This is why observation window and confidence levels are critical.

Query Store survives restarts. When available, Query Store provides a longer observation window (often 30+ days). The proc uses it as a confirmation signal: if Query Store has 30 days of plan history and none of those plans reference the index, that's strong evidence. If Query Store finds plan references but DMVs show zero usage, the index might be used by rare queries that haven't run since the last restart.

Detection Strategy

Phase 1: DMV Collection (Primary Signal)

Collects all nonclustered indexes where:

user_seeks = 0 AND user_scans = 0 AND user_lookups = 0 AND user_updates > 0

This means the index is being written to (maintained on every DML) but never read from by any query. The full index definition is captured including key column order (ASC/DESC), included columns, filter definitions, and fill factor — everything needed to recreate it later if the disable turns out to be wrong.

Phase 2: Query Store Confirmation (Optional)

When @WithQueryStore = 1 and the database has Query Store enabled with ≥ 30 days retention, the proc searches all stored execution plans for references to each flagged index. Uses CHARINDEX('[IndexName]', plan_text) with brackets for precise matching, avoiding false positives from substring matches.

Query Store is confirmation only — it runs against indexes already flagged by Phase 1. It never adds new indexes to the list.

Phase 3: Confidence Assignment

Each unused index is assigned a confidence level based on the strength of evidence:

Confidence Levels

LevelCriteriaWhat It MeansGuidance
HIGHQuery Store confirms zero plan references (30+ days), OR DMV-only with server uptime ≥ 30 daysStrong evidence: no query has touched this index in at least a month across all available observation sourcesSafe to disable. Very unlikely to be needed.
MEDIUMDMV-only, server uptime 14–29 daysReasonable evidence but the observation window is under a month. Monthly, quarterly, or seasonal queries could be missed.Probably safe to disable. Check if the table has any known batch/reporting processes that run monthly.
LOWServer uptime < 14 days (DMV window too short), OR Query Store found plans that reference the indexWeak evidence. Either the observation window is too short to draw conclusions, or there's active evidence the index IS used by some queries even though DMVs show zero recent reads.Do not disable without investigation. Review the referencing plans if Query Store found them.

The @Confidence parameter controls which levels generate active (uncommented) scripts. Indexes below the threshold still appear in the output but their action statements are wrapped in /* */ comments.

Actions: DISABLE vs DROP

ActionSQL GeneratedReversible?When to Use
DISABLE (default)ALTER INDEX [name] ON [table] DISABLE;Yes — re-enable with ALTER INDEX [name] ON [table] REBUILD;Default for most situations. The index metadata remains, the data pages are freed. If a query starts performing poorly, rebuild the index to restore it.
DROPDROP INDEX IF EXISTS [name] ON [table];No — must recreate from scratchOnly when you're confident the index is permanent waste AND you have the recreate script saved (the output includes it as a comment).

Why DISABLE is the safe default: A disabled index stays in sys.indexes as a reminder it existed. It takes zero space (data pages are deallocated). If you later discover a query needs it, ALTER INDEX ... REBUILD brings it back instantly. With DROP, you need the original CREATE INDEX statement, which the output provides as a comment — but it's easier to rebuild than recreate.

Observation Window

The @MinObservDays parameter prevents premature recommendations. If the server restarted 3 days ago, even a heavily-used index will show zero reads in DMVs.

Example: An index is used exclusively by a month-end financial close process that runs on the last business day. If you check DMV stats on the 5th of the month, the index has had zero reads for 5 days. With @MinObservDays = 7 it would be flagged. With @MinObservDays = 35 it would not, because the observation window would need to span an entire monthly cycle.

Recommendation: Use @MinObservDays = 14 minimum for production. For critical systems with monthly processes, consider @MinObservDays = 35.

Safety Exclusions

The following are never flagged as unused, regardless of DMV stats:

Write Overhead Filter

The @MinUpdateCount parameter filters out indexes with trivial write overhead. An unused index on a table that gets 5 updates per day isn't worth the operational effort of disabling it. The default of 100 filters out the noise; increase to 1000+ for large fleets where you want to focus on the highest-impact removals first.

Parameters

ParameterDefaultDescription
@RunAnalysis0BIT. When 1, runs sp_UnusedIndexAnalysis first to refresh results
@DatabaseNameNULLNULL = all user databases. Supports CSV list and LIKE wildcards
@TableNameNULLFilter to one table. NULL = all
@Action'DISABLE'DISABLE = ALTER INDEX ... DISABLE (reversible). DROP = DROP INDEX IF EXISTS (permanent)
@Confidence'HIGH'Minimum confidence for active (uncommented) scripts. Values: HIGH, MEDIUM, LOW. Below threshold = commented out
@MinUpdateCount100Minimum user_updates to flag. Filters indexes with trivial write overhead
@MinObservDays7Minimum observation window in days. Won't recommend with insufficient data
@WithQueryStore0BIT. When 1, enables Query Store plan scanning for HIGH confidence. Significantly slower

Note: No @RunnableUnder parameter — unlike tools that create indexes (which lock tables proportional to size), disabling or dropping an index is an instant metadata operation regardless of size.

Results Table

indexing.UnusedIndexResults — one row per unused nonclustered index:

ColumnDescription
IndexName, IndexKeys, IndexIncludesFull index definition
IndexSizeKBCurrent size (prioritization: largest savings first)
IsUnique, HasFilter, FilterDefinitionIndex characteristics (safety check: unique/filtered never flagged)
UserSeeks, UserScans, UserLookupsRead usage (always 0 for flagged indexes)
UserUpdatesWrite overhead since DMV reset — the cost of keeping this index
DMVStatsResetDateWhen DMV stats started accumulating (server start time)
ObservationSourceDMV_ONLY, QUERY_STORE, or BOTH
ObservationDaysMax observation window across all sources
ConfidenceHIGH, MEDIUM, or LOW
DropStatementReady-to-run DISABLE or DROP statement
RecreateStatementFull CREATE INDEX for rollback
ResolvedDateSet when the recommendation is applied

Data Flow

sp_UnusedIndexRecommendations(@RunAnalysis = 1)
  |
  +--> sp_UnusedIndexAnalysis(@DatabaseName)
  |      Per-database loop:
  |        Phase 1: Collect unused NC indexes from DMVs
  |          - sys.indexes + sys.dm_db_index_usage_stats
  |          - Filter: seeks=0, scans=0, lookups=0, updates>0
  |          - Skip: PKs, unique, clustered, CDC, hypothetical
  |          - Capture full definition + recreate script
  |        Phase 2: Query Store confirmation (optional)
  |          - Search plan XML for index name references
  |          - Count distinct plans per index
  |        Phase 3: Assign confidence + insert results
  |      -> writes indexing.UnusedIndexResults
  |
  +--> Generate ScriptText output ranked by size (largest savings first)
         - Recreate script as comment (rollback safety)
         - Observation details + confidence per index
         - UPDATE ResolvedDate after each action

Usage Examples

-- Analyze all databases, view results
EXEC indexing.sp_UnusedIndexRecommendations @RunAnalysis = 1;

-- Single database
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1, @DatabaseName = 'MyDatabase';

-- Wildcard pattern
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1, @DatabaseName = 'MyApp_%';

-- Only flag indexes with significant write overhead
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1, @MinUpdateCount = 10000;

-- Require at least 30 days of observation
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1, @MinObservDays = 30;

-- High-confidence with Query Store confirmation (slower)
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1, @WithQueryStore = 1;

-- Include MEDIUM confidence (commented out below threshold)
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1, @Confidence = 'MEDIUM';

-- Drop instead of disable (HIGH confidence only)
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1, @Action = 'DROP', @Confidence = 'HIGH';

-- Production recommendation: conservative settings
EXEC indexing.sp_UnusedIndexRecommendations
    @RunAnalysis = 1,
    @DatabaseName = 'MyApp_%',
    @Action = 'DISABLE',
    @Confidence = 'HIGH',
    @MinUpdateCount = 1000,
    @MinObservDays = 14;