Parameter Sniffing: Why It’s Fast for One User and Slow for Everyone Else
Parameter Sniffing: Why It’s Fast for One User and Slow for Everyone Else
Companion code for the DbModernizer blog post “Parameter Sniffing: Why It’s Fast for One User and Slow for Everyone Else”.
This example reproduces a textbook parameter-sniffing bug: a stored
procedure filters a heavily skewed column (OrderStatus, ~96% one value),
gets its first cached plan compiled for a rare parameter value, and then
serves every other caller a plan shaped for the wrong row count.
What this demonstrates
- The problem –
dbo.GetOrdersByStatuscompiles a seek-plus-lookup plan optimized for the rare'Pending'status, then reuses that same cached plan for'Completed'(96% of the table), turning a plan that should scan into one that performs a key lookup per row. - The expert fix – diagnosing it via
sys.dm_exec_procedure_stats, the cached plan’s estimated-vs-actual row counts, and confirming statistics aren’t stale before blaming sniffing. Then three fix options in order of preference:OPTIMIZE FORa representative value,OPTION (RECOMPILE), or splitting the rare/critical value into its own branch. - The AI-automation angle – a scheduled, read-only Query Store watcher that scores procedures by how much their duration varies across executions of the same cached plan, and produces a ranked report for a human to act on. It never applies a hint, forces a plan, or edits a procedure itself.
Prerequisites
- SQL Server 2016+ (Developer or Standard edition) or Azure SQL Database
- SQL Server Management Studio (SSMS) or
sqlcmd - A scratch/test instance – do not run this against production; it
creates a
ParamSniffDemodatabase and seeds ~500,000 rows - Query Store enabled for Part 3 (on by default for new databases in compatibility level 130+; the seed script sets compatibility level 150)
- Optional, for the scheduling wrapper: PowerShell with the
SqlServermodule (Install-Module SqlServer) if you want to runrun_plan_variance_watcher.ps1under SQL Agent or Task Scheduler
Files
| File | Purpose |
|---|---|
01_seed_schema.sql |
Creates ParamSniffDemo, the skewed Orders table, and the naive dbo.GetOrdersByStatus procedure |
02_reproduce_and_fix.sql |
Part A: reproduces the sniffed plan. Part B: diagnoses it with the plan cache and actual-vs-estimated rows. Part C: three fix options, cheapest/safest first |
03_ai_plan_variance_watcher.sql |
Read-only Query Store query that scores procedures by execution-duration variance and logs candidates to dbo.PlanVarianceLog |
run_plan_variance_watcher.ps1 |
Optional PowerShell wrapper to run the watcher on a schedule and surface flagged procedures |
Steps to reproduce
- Open SSMS (or
sqlcmd) and connect to a scratch SQL Server instance. - Run
01_seed_schema.sql. Seeding 500,000 rows in batches takes a couple of minutes. - Run Part A of
02_reproduce_and_fix.sql: it frees the plan cache for a clean repro, calls the procedure with'Pending'first, then calls it with'Completed'. Watch theSTATISTICS IOoutput on the second call – expect a very high logical read count relative to the table’s actual page count. - Run Part B to pull the cached plan’s stats from
sys.dm_exec_procedure_stats, then re-run with Include Actual Execution Plan on in SSMS and compare Estimated vs. Actual rows on the Index Seek / Key Lookup operators. A gap of two-plus orders of magnitude is the signature of sniffing, not a missing index. - Run Part C to apply Option 1 (
OPTIMIZE FOR), test, then try Option 2 (OPTION (RECOMPILE)) and Option 3 (branch on the skewed value) the same way – eachALTER PROCEDUREoverwrites the previous version so you can compare all three independently. - Re-run the free-cache-then-call-both-statuses sequence at the bottom of
Part C and confirm
'Completed'now gets a plan shaped for its own row count. - Optionally, run
03_ai_plan_variance_watcher.sql(after generating some mixed traffic against the procedure so Query Store has runtime stats to score) to see the variance-scoring watcher produce its ranked report. - Clean up when done:
DROP DATABASE ParamSniffDemo;
Notes
- The exact variance ratio and read counts will shift run to run depending on hardware and buffer pool state – the point is the pattern (huge swing in cost for the same cached plan), not a specific number.
OPTIMIZE FORand branching both require knowing your data’s skew in advance;OPTION (RECOMPILE)needs no such knowledge but costs CPU on every call, so it’s the right trade for low-frequency procs and the wrong one for something called thousands of times a second.- Query Store’s
sp_query_store_force_planis another lever worth knowing about once you’ve identified a specific known-good plan_id, but it’s a more surgical, harder-to-maintain fix than any of the three in this repo and is best reserved for cases where the other three don’t fit.