DBA Scripts: Get Statistics Health

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Performance & Troubleshooting › Query & Performance Tuning

Query plans are only as good as the statistics SQL Server uses to build them. When a table’s data distribution changes but its statistics don’t get refreshed to match, the optimizer keeps making decisions based on a picture of the data that’s no longer true, choosing a scan when a seek would be faster, or picking the wrong join order entirely. Stale statistics are one of the most common causes of a query that “used to be fast” and quietly isn’t anymore.

This script identifies statistics that are stale, sampled too thinly, or ageing with changes pending, ranked by severity, and hands back the exact UPDATE STATISTICS command to fix each one.


Why Statistics Health Matters

SQL Server’s query optimizer relies on statistics (a compact summary of data distribution) to estimate row counts and choose execution plans. When those estimates are wrong, everything downstream can be wrong too:

  • Bad cardinality estimates lead to bad plan choices: wrong join type, wrong index, wrong memory grant
  • Automatic updates only trigger on a threshold of row changes. On compatibility level 130 and later that is the lower of 500 + 20% of rows and SQRT(1000 × rows), so large tables still go a long time between refreshes, and the update itself waits for the next query that compiles against the statistic
  • A statistic sampled at a low percentage on a large table may not represent the real data distribution accurately
  • Never-updated statistics on tables that have grown significantly are a common, quiet source of bad plans

When to Run This Script

  • Routine SQL Server health checks
  • Investigating a query that regressed without any code change
  • Before and after a large data load, migration, or bulk delete
  • Reviewing a database you’ve just inherited

The Script

Run the following script against your SQL Server instance.

✓ Verified
  • Tested on: SQL Server 2025 (RTM-CU8, 17.0.4075.5), Windows lab instance
  • Last verified: 2026-10-24 (run against a lab database with statistics past the threshold and approaching it)
  • Permissions: SELECT on the tables, or db_owner. With VIEW DATABASE STATE alone it returns no rows at all, which reads as healthy
  • 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-StatisticsHealth
Category    : performance
Purpose     : Identifies stale, low-sample, and never-updated statistics in the current database.
              Returns the UPDATE STATISTICS command per row for direct copy-paste remediation.
              Run in the context of the target user database.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-statistics-health/)
Requires    : SELECT on the tables, or db_owner / db_ddladmin. sys.dm_db_stats_properties returns
              nothing for a table you cannot read, and VIEW DATABASE STATE alone does not unlock it.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;
-- SCOPE:CurrentDatabase
-- Fixes : the update_statement column contains the ready-to-run UPDATE STATISTICS command

DECLARE @show_all BIT = 0; -- 0 = stale / unhealthy only | 1 = all statistics
DECLARE @min_rows INT = 100; -- skip tables below this row count (reduces noise from tiny tables)
DECLARE @stale_days INT = 30; -- AGED threshold: flag stats not updated in this many days when
                                -- modification_counter > 0 (stats exist but are being ignored)

-- Dynamic update threshold (SQL 2016+ compat 130+): SQRT(1000 * rows)
-- Legacy threshold was 20% of row count; dynamic is more conservative on large tables.
-- Row counts come from sys.partitions: a statistic that was never built (created on an empty
-- table) has NULL rows in sys.dm_db_stats_properties and would otherwise fail the @min_rows filter.

WITH table_rows AS (
    SELECT p.object_id, SUM(p.rows) AS table_rows
    FROM sys.partitions p
    WHERE p.index_id IN (0, 1)
    GROUP BY p.object_id
),
stats_health AS (
    SELECT
        OBJECT_SCHEMA_NAME(s.object_id) AS schema_name,
        OBJECT_NAME(s.object_id) AS table_name,
        s.name AS stat_name,
        c.name AS leading_column,
        CASE
            WHEN s.auto_created = 0 AND s.user_created = 0 THEN 1
            ELSE 0
        END AS is_index_stat,
        s.auto_created,
        s.has_filter AS is_filtered,
        s.filter_definition,
        s.is_incremental,
        COALESCE(sp.rows, tr.table_rows) AS rows,
        sp.rows_sampled,
        CAST(sp.rows_sampled * 100.0
             / NULLIF(sp.rows, 0) AS DECIMAL(5,1)) AS sample_pct,
        sp.modification_counter,
        CAST(sp.modification_counter * 100.0
             / NULLIF(sp.rows, 0) AS DECIMAL(5,1)) AS modification_pct,
        CAST(SQRT(1000.0 * NULLIF(sp.rows, 0)) AS BIGINT) AS dynamic_update_threshold,
        sp.last_updated,
        DATEDIFF(DAY, sp.last_updated, GETDATE()) AS days_since_update,
        CASE
            WHEN sp.last_updated IS NULL
                THEN 'NEVER_UPDATED'
            WHEN sp.modification_counter >= SQRT(1000.0 * NULLIF(sp.rows, 0))
                THEN 'STALE_THRESHOLD_MET'
            WHEN sp.rows > 10000
             AND sp.rows_sampled * 100.0 / NULLIF(sp.rows, 0) < 10
                THEN 'LOW_SAMPLE_RATE'
            WHEN sp.modification_counter * 100.0 / NULLIF(sp.rows, 0) > 10
                THEN 'APPROACHING_STALE'
            WHEN sp.modification_counter > 0
             AND DATEDIFF(DAY, sp.last_updated, GETDATE()) > @stale_days
                THEN 'AGED'
            ELSE 'OK'
        END AS health_status,
        'UPDATE STATISTICS ['
            + OBJECT_SCHEMA_NAME(s.object_id) + '].['
            + OBJECT_NAME(s.object_id) + '] ['
            + s.name + '] WITH FULLSCAN;' AS update_statement
    FROM sys.stats s
    OUTER APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp
    JOIN sys.objects o
        ON s.object_id = o.object_id
        AND o.type = 'U'
    LEFT JOIN sys.stats_columns sc
        ON sc.object_id = s.object_id
        AND sc.stats_id = s.stats_id
        AND sc.stats_column_id = 1
    LEFT JOIN sys.columns c
        ON c.object_id = sc.object_id
        AND c.column_id = sc.column_id
    LEFT JOIN table_rows tr
        ON tr.object_id = s.object_id
    WHERE COALESCE(sp.rows, tr.table_rows, 0) >= @min_rows
)
SELECT
    schema_name,
    table_name,
    stat_name,
    leading_column,
    is_index_stat,
    auto_created,
    is_filtered,
    filter_definition,
    is_incremental,
    rows,
    rows_sampled,
    sample_pct,
    modification_counter,
    modification_pct,
    dynamic_update_threshold,
    last_updated,
    days_since_update,
    health_status,
    update_statement
FROM stats_health
WHERE @show_all = 1
   OR health_status <> 'OK'
ORDER BY
    CASE health_status
        WHEN 'NEVER_UPDATED' THEN 1
        WHEN 'STALE_THRESHOLD_MET' THEN 2
        WHEN 'LOW_SAMPLE_RATE' THEN 3
        WHEN 'APPROACHING_STALE' THEN 4
        WHEN 'AGED' THEN 5
        ELSE 6
    END,
    modification_counter DESC,
    schema_name,
    table_name;

The script evaluates every statistic in the current database against four unhealthy patterns (past the dynamic staleness threshold, thinly sampled on a large table, more than 10% modified, or ageing with pending modifications) and returns only the ones that need attention, each with a ready-to-run UPDATE STATISTICS command.


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

# Check statistics health in a specific database:
.\run.ps1 Get-StatisticsHealth -Database YourDatabaseName

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

This script lives in the repo at:


Example Output

Get-StatisticsHealth output showing key columns, SQL Server output

Run against a single database (-Database RandomLab in this example): at 14.2% of rows modified since the last update on IX_IndexDemo_Status_Unused, this statistic hasn’t crossed the dynamic staleness threshold yet, but it’s heading there, exactly what APPROACHING_STALE is meant to catch before it becomes a problem.


Understanding the Results

STALE_THRESHOLD_MET
The modification counter is past SQRT(1000 × rows), shown in dynamic_update_threshold. The optimizer is planning from a distribution that has moved since the statistic was built. Act when the table sits behind a query that regressed, or the database has AUTO_UPDATE_STATISTICS off. Run its update_statement, then re-test the query.
LOW_SAMPLE_RATE
Sampled below 10% on a table over 10,000 rows. Fine for evenly spread data, misleading on a skewed column where a few values hold most of the rows. Act when the leading column is skewed, or plans built on it estimate far fewer rows than they return.
APPROACHING_STALE
More than 10% of rows modified, still under the dynamic threshold. Worth watching rather than fixing. Act when a heavy load or a reporting window is coming; otherwise the next maintenance run covers it.
AGED
Some modifications pending and no update in more than @stale_days (30). Low volume, but old. Act when the maintenance job should have covered it, because that means the job is skipping this table.
update_statement
A ready-to-run UPDATE STATISTICS ... WITH FULLSCAN for that exact statistic.

How to Fix Statistics Health

Run the update_statement column’s commands for anything at STALE_THRESHOLD_MET first; those are the highest-impact fixes. WITH FULLSCAN gives the most accurate statistics but takes longer and uses more resources than a sampled update, better suited to a maintenance window on large tables.

-- Example: update a single statistic with a full scan
UPDATE STATISTICS [dbo].[IndexDemo] [IX_IndexDemo_Status_Unused] WITH FULLSCAN;

-- Or update all statistics on a table (sampled, faster)
UPDATE STATISTICS [dbo].[IndexDemo];

If stale statistics are a recurring problem rather than a one-off, schedule it rather than fixing it by hand every time: Generate Index Maintenance Jobs writes a DBA - Statistics Update Agent job that runs sp_updatestats on every online user database.


Best Practices

  • Include a regular statistics maintenance job as part of routine index maintenance, not a separate, forgotten task. An index rebuild already refreshes that index’s statistics with a full scan; a reorganize does not, and neither touches column statistics.
  • After a large bulk load or delete, update statistics manually rather than waiting for the automatic threshold to trigger.
  • Consider AUTO_UPDATE_STATISTICS_ASYNC for busy OLTP systems where synchronous statistics updates cause noticeable query stalls, which show up as WAIT_ON_SYNC_STATISTICS_REFRESH.
  • Re-run this script after any statistics maintenance to confirm the fix actually landed.

Microsoft Learn: sys.stats · sys.dm_db_stats_properties


Frequently Asked Questions

How often does SQL Server automatically update statistics?

When the modification counter crosses a threshold and a query then compiles against the statistic. On compatibility level 130 and later the threshold is the lower of 500 + 20% of rows and SQRT(1000 × rows), which for a 50,000-row table is 7,071 changes. On large, slowly-changing tables that can take a long time to reach, leaving statistics stale in the meantime.

Is FULLSCAN always better than a sampled update?

More accurate, not always better in practice. FULLSCAN costs more time and resources, which matters on very large tables. A sampled update is often good enough and much faster; reserve FULLSCAN for tables where accuracy has caused real plan problems.


Summary

Stale statistics are an easy thing to overlook because they don’t throw an error, they just quietly make the optimizer’s job harder. A query that regressed with no code change is one of the first things worth checking statistics health for.

Run this script as part of routine health checks, and always after a large data load or bulk delete, since those are exactly the events that push statistics out of date fastest.


Related

You may also find these useful:

Comments

Leave a Reply

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