Stale Statistics: The Silent Killer of Good Execution Plans
Stale Statistics: The Silent Killer of Good Execution Plans
Companion code for the DbModernizer blog post “Stale Statistics: The Silent Killer of Good Execution Plans”.
This example reproduces the “ascending key” flavor of stale statistics –
the one that doesn’t show up as a fragmentation alarm or a maintenance-job
failure, because nothing is broken. A steadily-growing OrderDate column
gets nightly inserts that never come close to the row-modification
threshold SQL Server uses to trigger an automatic statistics update on a
large table, so the histogram quietly stops reflecting reality. A query
filtering on recent dates gets a cardinality estimate of about 1 row when
the real answer is 20,000, and the optimizer picks a plan sized for the
wrong number.
What this demonstrates
- The problem – an 8-million-row
Orderstable where three weeks of realistic nightly ETL batches land past the edge of the last full-scan statistics update, without ever triggering an automatic refresh, verified withsys.dm_db_stats_propertiesand a query whose estimated-vs-actual row counts are off by four orders of magnitude. - The expert fix – a targeted
UPDATE STATISTICS ... WITH FULLSCANon the affected index, with before/after evidence fromsys.dm_db_stats_propertiesandSET STATISTICS IO, TIME. - The AI-automation angle – a scheduled, read-only watcher that
scores every statistic on the instance by both staleness (days since
last update) and drift (modifications as a percentage of the row
count at last update), logging candidates to a review table. It never
runs
UPDATE STATISTICSitself; a human reviews the report and decides what to run and when.
Prerequisites
- SQL Server 2019+ (Developer, Standard, or Enterprise edition) or Azure SQL Database
- A scratch/test instance – this script creates its own
StaleStatsDemodatabase and drops/recreatesdbo.Ordersif it already exists - A few minutes of runtime and roughly 1-2 GB of free space for the seed data (~8 million base rows plus ~420,000 “nightly ETL” rows)
How to run
-
Seed the problem
sqlcmd -S <your-instance> -i 01_seed_schema.sqlThis creates
StaleStatsDemo, buildsdbo.Orderswith a nonclustered index onOrderDate, loads ~8,000,000 rows of history dated over the prior 3 years, runs aFULLSCANstatistics update to establish a realistic “healthy” histogram baseline, then loads 21 more batches of 20,000 rows each dated into the days immediately after that baseline – simulating three weeks of nightly ETL that never trigger an auto-update on a table this size. -
Reproduce and fix the problem
sqlcmd -S <your-instance> -i 02_diagnose_and_fix.sqlRun this in SSMS with “Include Actual Execution Plan” on (Ctrl+M) to see the estimated-vs-actual row count mismatch directly. The script checks
sys.dm_db_stats_properties, runs the affected query once against the stale histogram, updates statistics withFULLSCAN, then re-runs the same query so you can compare plans and I/O directly. -
Run the AI-automation watcher
sqlcmd -S <your-instance> -i 03_ai_stats_drift_watcher.sqlCreates
dbo.StatsDriftLogif it doesn’t exist, scores every statistic in the database, and returns only the rows worth a look (UPDATE_NOWorREVIEW). Schedule this daily (seerun_watcher.ps1) against each database that matters – it only reads and logs, it never modifies statistics on its own.
Files
01_seed_schema.sql– builds the demo database and reproduces the ascending-key drift scenario02_diagnose_and_fix.sql– diagnoses the bad cardinality estimate and applies the fix, with before/after evidence03_ai_stats_drift_watcher.sql– the read-only scheduled watcher and its log tablerun_watcher.ps1– a minimal wrapper for running the watcher on a schedule (Windows Task Scheduler or SQL Agent CmdExec step)