Performance 10: statistics health

Run it in the database: the auto-statistics settings, how fresh and how well sampled every statistic is, and filtered statistics that have fallen behind. A fuller version of Performance 05. Read-only, changes nothing. Tested on SQL Server 2017 and 2025.

/*
 10_Statistics_Health.sql - run IN the database: how fresh and how good every statistic is, the database's
 auto-statistics settings, and filtered statistics that have fallen behind.
 Clive Roberts DBA, 10 October 2026. Read-only. SQL Server 2016 or later. (A fuller version of 05_Statistics_Age.sql.)

 HOW TO READ IT
   Result 1 - the database's settings. AUTO_CREATE and AUTO_UPDATE should normally be ON. AUTO_UPDATE_ASYNC ON
              means queries don't wait for a stats update (the next compile gets the new stats) - good for OLTP.
   Result 2 - each statistic, worst first. flags:
     STALE            more rows changed than SQL Server's own auto-update threshold (sqrt(1000 x rows) under
                      compatibility level 130+, or 20% + 500 rows under older levels). It updates on the next query
                      that needs it - unless the table is busy and you'd rather update it in a maintenance window.
     LOW SAMPLE       a big table (over 1M rows) sampled below 5%: lopsided data can be missed. Consider
                      UPDATE STATISTICS ... WITH SAMPLE 25 PERCENT (or FULLSCAN) and PERSIST_SAMPLE_PERCENT = ON.
     NORECOMPUTE      auto-update is switched off for this statistic: it will only change when someone updates it.
     FILTERED BEHIND  a filtered statistic not updated in 7 days although its table has changed: filtered
                      statistics only auto-update on whole-table changes, so they fall behind. Update them on a
                      schedule (Ola's IndexOptimize @UpdateStatistics = 'ALL', @OnlyModifiedStatistics = 'Y').
     NEVER UPDATED    no statistics have been built yet (empty table when created, or created WITH NORECOMPUTE).
   persisted_sample_pct  a sample rate that UPDATE STATISTICS keeps using (SQL 2016 SP1 CU4+); NULL = default.
*/
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- Result 1: this database's statistics settings
SELECT name AS [database], compatibility_level, is_auto_create_stats_on AS auto_create, is_auto_update_stats_on AS auto_update,
       is_auto_update_stats_async_on AS auto_update_async, is_auto_create_stats_incremental_on AS auto_create_incremental
FROM sys.databases WHERE database_id = DB_ID();

-- Result 2: every statistic on user tables
DECLARE @compat int = (SELECT compatibility_level FROM sys.databases WHERE database_id = DB_ID());
DECLARE @persisted nvarchar(100) =
    CASE WHEN EXISTS (SELECT 1 FROM sys.all_columns WHERE object_id = OBJECT_ID(N'sys.dm_db_stats_properties') AND name = N'persisted_sample_percent')
         THEN N'sp.persisted_sample_percent' ELSE N'CONVERT(float, NULL)' END;
DECLARE @sql nvarchar(max) = N'
SELECT OBJECT_SCHEMA_NAME(s.object_id) + N''.'' + OBJECT_NAME(s.object_id) AS [table], s.name AS [statistic],
       STUFF((SELECT N'', '' + c.name FROM sys.stats_columns AS sc JOIN sys.columns AS c ON c.object_id = sc.object_id AND c.column_id = sc.column_id
              WHERE sc.object_id = s.object_id AND sc.stats_id = s.stats_id ORDER BY sc.stats_column_id FOR XML PATH(''''), TYPE).value(''.'', ''nvarchar(max)''), 1, 2, N'''') AS [columns],
       CASE WHEN s.auto_created = 1 THEN ''auto'' WHEN s.user_created = 1 THEN ''user'' ELSE ''index'' END AS made_by,
       sp.last_updated, DATEDIFF(DAY, sp.last_updated, GETDATE()) AS days_old,
       sp.rows, sp.rows_sampled, CONVERT(decimal(6,2), 100.0 * sp.rows_sampled / NULLIF(sp.rows, 0)) AS sample_pct,
       ' + @persisted + N' AS persisted_sample_pct,
       sp.modification_counter AS rows_changed, CONVERT(decimal(9,1), 100.0 * sp.modification_counter / NULLIF(sp.rows, 0)) AS pct_changed,
       s.has_filter, s.filter_definition, s.no_recompute, s.is_incremental,
       LTRIM(
         CASE WHEN sp.last_updated IS NULL THEN '' NEVER UPDATED'' ELSE '''' END
       + CASE WHEN sp.rows > 0 AND sp.modification_counter > CASE WHEN @compat >= 130 THEN SQRT(1000.0 * sp.rows) ELSE 500 + 0.2 * sp.rows END
              THEN '' STALE'' ELSE '''' END
       + CASE WHEN sp.rows > 1000000 AND 100.0 * sp.rows_sampled / NULLIF(sp.rows, 0) < 5 THEN '' LOW SAMPLE'' ELSE '''' END
       + CASE WHEN s.no_recompute = 1 THEN '' NORECOMPUTE'' ELSE '''' END
       + CASE WHEN s.has_filter = 1 AND sp.last_updated < DATEADD(DAY, -7, GETDATE())
                   AND EXISTS (SELECT 1 FROM sys.dm_db_index_usage_stats AS us WHERE us.database_id = DB_ID() AND us.object_id = s.object_id
                               AND us.last_user_update > sp.last_updated)
              THEN '' FILTERED BEHIND'' ELSE '''' END) AS flags
FROM sys.stats AS s
OUTER APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE OBJECTPROPERTY(s.object_id, ''IsUserTable'') = 1 AND OBJECTPROPERTY(s.object_id, ''IsMsShipped'') = 0
ORDER BY CASE WHEN sp.last_updated IS NULL THEN 0 ELSE 1 END, pct_changed DESC, sp.rows DESC;';
EXEC sys.sp_executesql @sql, N'@compat int', @compat;

-- Result 3: summary
SELECT COUNT(*) AS statistics_total,
       SUM(CASE WHEN s.auto_created = 1 THEN 1 ELSE 0 END) AS auto_created,
       SUM(CASE WHEN s.has_filter = 1 THEN 1 ELSE 0 END) AS filtered,
       SUM(CASE WHEN s.no_recompute = 1 THEN 1 ELSE 0 END) AS norecompute,
       SUM(CASE WHEN sp.rows > 0 AND sp.modification_counter > CASE WHEN @compat >= 130 THEN SQRT(1000.0 * sp.rows) ELSE 500 + 0.2 * sp.rows END THEN 1 ELSE 0 END) AS stale,
       MIN(sp.last_updated) AS oldest_update
FROM sys.stats AS s OUTER APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE OBJECTPROPERTY(s.object_id, 'IsUserTable') = 1 AND OBJECTPROPERTY(s.object_id, 'IsMsShipped') = 0;