Problem
In my previous tip, Managing Unused Indexes in SQL Server Availability Groups – Part 1, I showed how I use dynamic SQL to gather index usage statistics for a given table from all replicas in an availability group. Knowing the usage from all workloads is definitely better than focusing on only the primary or a single secondary. But what if I want to make more informed decisions, incorporating row counts, size, and index columns into this output?
Solution

The previous tip showed a table like this:
CREATE TABLE #IndexUsage ( ServerName nvarchar(128), IndexName nvarchar(128), UserSeeks bigint, UserScans bigint, UserLookups bigint, UserUpdates bigint, LastScan datetime, LastSeek datetime );
We populated it using this function:
CREATE OR ALTER FUNCTION dbo.GetOtherReplicas ( @DatabaseName sysname ) RETURNS TABLE AS RETURN ( SELECT ServerName = s.name FROM master.sys.availability_databases_cluster AS dc INNER JOIN master.sys.availability_replicas AS ar ON dc.group_id = ar.group_id LEFT OUTER JOIN sys.servers AS s ON ar.replica_server_name = s.name WHERE dc.database_name = @DatabaseName AND LOWER(ar.replica_server_name) NOT IN ( LOWER(@@SERVERNAME), LOWER(CONVERT(sysname, SERVERPROPERTY('ServerName'))), LOWER(CONVERT(sysname, SERVERPROPERTY('ComputerNamePhysicalNetBIOS'))) ) );
And then this code:
DECLARE @DatabaseName sysname = N'Database I''m After', @TableName sysname = N'dbo.BasketWeavingStats'; DECLARE @Counter tinyint = 1, @ServerCount tinyint, @RemoteExec nvarchar(384), @LocalExec nvarchar(256) = QUOTENAME(@DatabaseName) + N'.sys.sp_executesql'; DROP TABLE IF EXISTS #servers; CREATE TABLE #servers ( id int identity(1,1), ServerName sysname ); INSERT #servers(ServerName) SELECT ServerName FROM dbo.GetOtherReplicas(@DatabaseName); SELECT @ServerCount = @@ROWCOUNT; DECLARE @sql nvarchar(max) = N'SELECT @@SERVERNAME, i.name, s.user_seeks, s.user_scans, s.user_lookups, s.user_updates, s.last_user_scan, s.last_user_seek FROM sys.indexes AS i LEFT OUTER JOIN sys.dm_db_index_usage_stats AS s ON i.object_id = s.object_id AND i.index_id = s.index_id AND s.database_id = DB_ID() WHERE i.object_id = OBJECT_ID(@TableName) AND i.index_id NOT IN (0,1);'; INSERT #IndexUsage EXEC @LocalExec @sql, N'@TableName sysname', @TableName; WHILE @Counter <= @ServerCount BEGIN SELECT @RemoteExec = QUOTENAME(ServerName) + N'.' + @LocalExec FROM #servers WHERE id = @Counter AND ServerName IS NOT NULL; INSERT #IndexUsage EXEC @RemoteExec @sql, N'@TableName sysname', @TableName; SET @Counter += 1; END
Adding Additional Index Information
With that table populated, now I can add more context.
Alongside usage statistics, I always review:
- How many rows are in a filtered index – This helps to determine whether it is valuable to maintain; is it 99% of the table, 1%, or something in between?
- How much space each index occupies – If an index is large but isn’t being read, it may be a priority to drop and/or review potential usage way ahead of smaller indexes.
- What are the key/include columns and what order are the key columns in – Often I review specific queries (and query changes) and want a quick way to see if a given index covers – or almost covers – a query or query pattern.
- What is the filter definition – Filtered indexes are often created to tailor-match a very specific query, and listing the explicit filter makes it easy to identify indexes that are candidates for consolidation.
For this, I can join to sys.indexes
, sys.dm_db_partition_stats
, sys.index_columns
, and sys.columns
locally. There’s no need to query remotely, even on a secondary, since these catalog details are consistent across replicas. The only exceptions are unlucky timing or suspended data movement.
I’ll still need dynamic SQL, though, since I ultimately want to put this into a stored procedure that takes the database name as a parameter. We do have some 400 databases, after all.
Query to Get Index Information
First, let’s look at a static query required to get this information when the #temp table is already:
USE [Database I'm after]; DECLARE @TableName sysname = N'dbo.BasketWeavingStats'; DECLARE @obj int = OBJECT_ID(@TableName); WITH IndexUsage AS ( SELECT IndexName, Reads = SUM(COALESCE(UserSeeks, 0) + COALESCE(UserScans, 0) + COALESCE(UserLookups, 0)), Writes = SUM(COALESCE(UserUpdates, 0)), LastRead = MAX(COALESCE(LastSeek, LastScan)) FROM #IndexUsage GROUP BY IndexName ), ColumnLists AS ( SELECT ic.index_id, KeyColumns = STRING_AGG(CASE ic.is_included_column WHEN 0 THEN CONCAT(c.name, CASE ic.is_descending_key WHEN 1 THEN N' (↓)' END) END, ',') WITHIN GROUP (ORDER BY ic.index_column_id), IncludeColumns = STRING_AGG(CASE ic.is_included_column WHEN 1 THEN c.name END, ',') FROM sys.index_columns AS ic INNER JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE ic.object_id = @obj GROUP BY ic.index_id ), IndexInfo AS ( SELECT i.index_id, IndexName = i.name COLLATE DATABASE_DEFAULT, ps.SizeMB, ps.Rows, i.filter_definition FROM sys.indexes AS i CROSS APPLY ( SELECT SizeMB = SUM(ps.used_page_count)/128, Rows = SUM(row_count) FROM sys.dm_db_partition_stats AS ps WHERE ps.object_id = @obj AND ps.index_id = i.index_id ) AS ps WHERE i.object_id = @obj ) SELECT ii.IndexName, iu.Reads, iu.LastRead, iu.Writes, ii.Rows, ii.SizeMB, TotalSizeMB = SUM(ii.SizeMB) OVER(), cl.KeyColumns, cl.IncludeColumns, ii.filter_definition FROM IndexInfo AS ii INNER JOIN ColumnLists AS cl ON cl.index_id = ii.index_id INNER JOIN IndexUsage AS iu ON iu.IndexName = ii.IndexName;
This produces output like this (sorry for obscurity, but the index and column names of the actual table I’m looking at are not important):

Stored Procedure for Index Usage
Now let’s put it all together in a single stored procedure so the call is really easy:
CREATE OR ALTER PROCEDURE dbo.MSSQLTips_IndexUsage @DatabaseName sysname = N'Database I''m after ', @TableName sysname = N'dbo.BasketWeavingStats' AS BEGIN SET NOCOUNT ON; DECLARE @Counter tinyint = 1, @ServerCount tinyint, @RemoteExec nvarchar(384), @LocalExec nvarchar(256) = QUOTENAME(@DatabaseName) + N'.sys.sp_executesql'; CREATE TABLE #servers ( id int identity(1,1), ServerName sysname ); INSERT #servers(ServerName) SELECT ServerName FROM dbo.GetOtherReplicas(@DatabaseName); SELECT @ServerCount = @@ROWCOUNT; DECLARE @sql nvarchar(max) = N'SELECT @@SERVERNAME, i.name, s.user_seeks, s.user_scans, s.user_lookups, s.user_updates, s.last_user_scan, s.last_user_seek FROM sys.indexes AS i LEFT OUTER JOIN sys.dm_db_index_usage_stats AS s ON i.object_id = s.object_id AND i.index_id = s.index_id AND s.database_id = DB_ID() WHERE i.object_id = OBJECT_ID(@TableName) AND i.index_id NOT IN (0,1);'; CREATE TABLE #IndexUsage ( ServerName nvarchar(128), IndexName nvarchar(128), UserSeeks bigint, UserScans bigint, UserLookups bigint, UserUpdates bigint, LastScan datetime, LastSeek datetime ); INSERT #IndexUsage EXEC @LocalExec @sql, N'@TableName sysname', @TableName; WHILE @Counter <= @ServerCount BEGIN SELECT @RemoteExec = QUOTENAME(ServerName) + N'.' + @LocalExec FROM #servers WHERE id = @Counter AND ServerName IS NOT NULL; INSERT #IndexUsage EXEC @RemoteExec @sql, N'@TableName sysname', @TableName; SET @Counter += 1; END SET @sql = N'DECLARE @obj int = OBJECT_ID(@TableName); WITH IndexUsage AS ( SELECT IndexName, Reads = SUM(COALESCE(UserSeeks, 0) + COALESCE(UserScans, 0) + COALESCE(UserLookups, 0)), Writes = SUM(COALESCE(UserUpdates, 0)), LastRead = MAX(COALESCE(LastSeek, LastScan)) FROM #IndexUsage GROUP BY IndexName ), ColumnLists AS ( SELECT ic.index_id, KeyColumns = STRING_AGG(CASE ic.is_included_column WHEN 0 THEN CONCAT(c.name, CASE ic.is_descending_key WHEN 1 THEN N'' (↓)'' END) END, '','') WITHIN GROUP (ORDER BY ic.index_column_id), IncludeColumns = STRING_AGG(CASE ic.is_included_column WHEN 1 THEN c.name END, '','') FROM sys.index_columns AS ic INNER JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE ic.object_id = @obj GROUP BY ic.index_id ), IndexInfo AS ( SELECT i.index_id, IndexName = i.name COLLATE DATABASE_DEFAULT, ps.SizeMB, ps.Rows, i.filter_definition FROM sys.indexes AS i CROSS APPLY ( SELECT SizeMB = SUM(ps.used_page_count)/128, Rows = SUM(row_count) FROM sys.dm_db_partition_stats AS ps WHERE ps.object_id = @obj AND ps.index_id = i.index_id ) AS ps WHERE i.object_id = @obj ) SELECT ii.IndexName, iu.Reads, iu.LastRead, iu.Writes, ii.Rows, ii.SizeMB, TotalSizeMB = SUM(ii.SizeMB) OVER(), cl.KeyColumns, cl.IncludeColumns, ii.filter_definition FROM IndexInfo AS ii INNER JOIN ColumnLists AS cl ON cl.index_id = ii.index_id INNER JOIN IndexUsage AS iu ON iu.IndexName = ii.IndexName;'; EXEC @LocalExec @sql, N'@TableName sysname', @TableName; END
Now we can get that same helpful output with:
EXEC dbo.MSSQLTips_IndexUsage @DatabaseName = N'Database I''m After', @TableName = N'dbo.BasketWeavingStats';
Analyzing the Data
As you go on this journey and start analyzing this type of output, don’t devalue indexes that are large and/or have many writes, but few reads. A few qualitative things to consider once you have the quantitative information:
- An index may be infrequently used, but by a very important person or a performance-critical process.
- An index may not be represented in the DMV for a given replica yet.
- Just because the same columns are in each key doesn’t make two indexes redundant – think about key column order, include columns, and filters.
I discuss more about this in a short post: Index cleanup: Harder than it looks.
Conclusion
This pattern could be used for any type of data you need to aggregate across replicas. The missing indexes DMVs would be a translatable candidate, for example. But, for this tip series, I wanted to focus on only one concept.
Next Steps
Review the following tips and other resources:
- Deeper insight into used and unused indexes for SQL Server
- How to get index usage information in SQL Server
- Collect SQL Server Index Usage for All Instances with PowerShell
- Tim Ford’s series on retaining historical index usage statistics: