DBA Scripts: Get Index Usage Stats

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Maintenance & AutomationIndex Maintenance

The Raw Usage Data Behind Every Index Decision

Unused Indexes already filters straight to drop candidates in one database. This script is the raw data underneath that filter, and more: every index across every user database, its seeks, scans, lookups, and updates, classified into a usage pattern, with no filtering applied. It’s the source data you’d want when the filtered “unused indexes” list isn’t quite the question you’re asking, cross-database usage patterns, write-heavy indexes, or scan-heavy tables that might need a better index instead of just removing a bad one.


Why Index Usage Stats Matter

  • usage_pattern = WRITE_ONLY is the same signal Unused Indexes filters for, but seeing it across every database in one pass, rather than one database at a time, is useful when reviewing a whole instance
  • usage_pattern = SCAN_HEAVY (scans far outnumbering seeks) points the other direction, toward a missing or ineffective index, not a redundant one, a table doing full scans instead of seeks needs attention even though the index isn’t “unused”
  • These counters reset on every SQL Server restart, a recently-restarted instance will show artificially low numbers across the board, not a true picture of long-term usage
  • Cross-database visibility in one query is genuinely useful when reviewing an instance with many databases, rather than switching context repeatedly

When to Run This Script

  • Instance-wide index usage review, especially on a server with many databases
  • Investigating a specific table’s scan/seek balance as part of a performance investigation
  • Before committing to Unused Indexes’ drop recommendations, to see the same data in a broader, unfiltered context
  • After several days of representative workload, running immediately after a restart gives an incomplete picture

The Script

✓ Verified
  • Tested on: SQL Server 2025 (RTM CU8), Windows lab instance
  • Last verified: 2026-09-02 (saved output from a real run, Get-IndexUsageStats-20260902-232806.csv)
  • Permissions: VIEW SERVER STATE, VIEW ANY DATABASE
  • Safety: read-only, impact low
Any thresholds in this script are operational heuristics; claim types are labelled where they appear in the text.
/*
Script Name : Get-IndexUsageStats
Category    : performance-troubleshooting
Purpose     : Show how indexes across all user databases are being used — seeks, scans, lookups, updates.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-index-usage-stats/)
Requires    : VIEW SERVER STATE, VIEW ANY DATABASE
Notes       : Usage counters reset on SQL Server restart. High user_updates with low reads =
              candidate for removal. high user_scans = possible missing index on that table.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

SELECT
    DB_NAME(ius.database_id) AS database_name,
    OBJECT_SCHEMA_NAME(ius.object_id, ius.database_id) AS schema_name,
    OBJECT_NAME(ius.object_id, ius.database_id) AS table_name,
    ius.index_id,
    ius.user_seeks,
    ius.user_scans,
    ius.user_lookups,
    ius.user_updates,
    ius.user_seeks + ius.user_scans + ius.user_lookups AS total_reads,
    CASE
        WHEN ius.user_seeks + ius.user_scans + ius.user_lookups = 0
         AND ius.user_updates > 0
            THEN 'WRITE_ONLY'
        WHEN ius.user_scans > ius.user_seeks * 10
            THEN 'SCAN_HEAVY'
        ELSE 'NORMAL'
    END AS usage_pattern,
    ius.last_user_seek,
    ius.last_user_scan,
    ius.last_user_update
FROM sys.dm_db_index_usage_stats AS ius
WHERE ius.database_id > 4
ORDER BY ius.user_updates DESC, total_reads DESC;

How To Run From The Repo

Clone DBA Tools, initialize and run the script:

# Clone dba-tools repo:
git clone https://github.com/peterwhyte-lgtm/dba-tools

# Initialize environment:
cd dba-tools
.\Initialize-Environment.ps1

# Index usage across every user database:
.\run.ps1 Get-IndexUsageStats

# To run against a remote sql server:
.\run.ps1 Get-IndexUsageStats -ServerInstance SQLSERVER01

This script lives in the repo at:


Example Output

SSMS results grid showing Get-IndexUsageStats returning 58 rows with seeks, scans, lookups, updates and a usage_pattern of SCAN_HEAVY, WRITE_ONLY or NORMAL for each index

One row per index that SQL Server has actually touched since the last restart, ordered by write cost first, then reads. The pattern to look for is a wide gap between the two: an index the server is paying to maintain and nobody is reading.


Understanding the Results

database_name
schema_name
table_name
Where the index lives, resolved from the ids the DMV actually stores. The script filters to database_id > 4, so system databases are excluded.
index_id
0 is a heap, 1 is the clustered index, anything above 1 is nonclustered. Note what is not here: the index name. This DMV is instance-wide while index names live inside each database, so the script can only report the id.Act when you are about to act on a finding. Look the id up in that database first (sys.indexes), because an id alone is not something you can safely drop.
user_seeks
user_scans
user_lookups
The three read counters. A seek is a targeted lookup, a scan reads a range or the whole thing, and a lookup is the key lookup back to the clustered index after a nonclustered hit.Act when scans dominate seeks on a large table. That is the optimiser telling you it had nothing selective enough to seek on.
user_updates
How many times the index had to be maintained because the underlying data changed. This is the cost side of the ledger, and it is charged whether or not anyone reads the index.
total_reads
Computed by the script as user_seeks + user_scans + user_lookups, so you can weigh reads against user_updates without adding three columns up by eye.
usage_pattern
The script’s own verdict, and worth knowing exactly how it is reached: WRITE_ONLY when reads are zero and updates are not, SCAN_HEAVY when scans exceed seeks by more than ten times, otherwise NORMAL.Act when it reads WRITE_ONLY on a nonclustered index, which is a genuine drop candidate. On index_id = 1 it means only that the table is written far more than it is read, which is exactly what a log or event table should look like.
last_user_seek
last_user_scan
last_user_update
When each kind of access last happened. The counts tell you how much, these tell you how recently, which is what separates an index that is genuinely idle from one used monthly.Act when a column is empty. That access has not happened at all since the counters last reset, not that it happened long ago.

The most important thing this script cannot show you is an index that is missing from it. sys.dm_db_index_usage_stats only holds a row once an index has been read or written, so an index nobody has touched since the last restart does not appear here at all. Absence is a finding, not a clean bill of health, and it is the opposite of what the list looks like it is telling you. Counters also reset on restart, so check the uptime with OS and Hardware Info before reading anything into a small number.


Best Practices

  • Run this instance-wide before drilling into Unused Indexes on a specific database, it gives the broader context the filtered list doesn’t show
  • Don’t trust the numbers immediately after a restart, wait for at least a few days of representative workload
  • Treat SCAN_HEAVY findings as an indexing opportunity, not just a curiosity, a genuinely useful index could turn those scans into seeks
  • Cross-reference clustered index (index_id = 1) WRITE_ONLY findings against the table’s actual purpose before assuming anything is wrong, a write-heavy log or event table is supposed to look like this

Microsoft’s reference covers sys.dm_db_index_usage_stats in full.


Related Scripts

You may also find these scripts useful:


Frequently Asked Questions

How is this different from Unused Indexes?

Unused Indexes filters straight to a specific, actionable list: non-clustered indexes with zero reads and non-zero writes, in one database, with a ready-to-run DROP INDEX statement. This script is the unfiltered source data across every database, useful when you want the fuller picture, including scan-heavy tables that need a better index, not just indexes worth removing.

Why do the same table’s indexes show completely different usage patterns?

Different indexes on the same table often serve different queries. A clustered index might see heavy writes and few direct reads while a non-clustered covering index on the same table sees frequent seeks, that’s normal and expected, not a sign either one is misconfigured.


Summary

Every index-removal or index-addition decision should be grounded in real usage data, not a guess. This script is that raw data, seeks, scans, lookups, and updates across every index on the instance, classified into a pattern that points toward either a genuine drop candidate or a missing-index opportunity.

Run it instance-wide as a starting point, then reach for Unused Indexes’ filtered, actionable list once you know which database and which pattern you’re chasing.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *