SQL Server Parameter Sniffing: Diagnosis and Fixes for Bad Query Plans (2026)

SQL Server Parameter Sniffing

Parameter sniffing is SQL Server's plan-caching optimization where the first execution of a parameterized query compiles a plan optimized for the supplied parameter values. It works brilliantly — until the first execution happens to use an atypical value, locking in a plan that is catastrophic for all subsequent calls.

This guide explains exactly how plan caching and parameter sniffing interact, how to identify sniffing problems using DMVs and Query Store, and which of the five standard fixes to apply depending on your workload characteristics.

How Parameter Sniffing Works

When SQL Server compiles a stored procedure or parameterized query for the first time, the optimizer peeks at the actual parameter values supplied. It uses those values to look up the statistics histogram and estimate row counts, then compiles a plan optimized for that cardinality.

-- Procedure compiled with @StatusID = 5 (rare status, 100 rows)
-- Optimizer estimates 100 rows → chooses Index Seek + Key Lookup
CREATE OR ALTER PROCEDURE dbo.GetOrdersByStatus
    @StatusID INT
AS
    SELECT OrderID, CustomerID, Total, OrderDate
    FROM dbo.Orders
    WHERE StatusID = @StatusID
    ORDER BY OrderDate DESC;

-- First call: rare status
EXEC dbo.GetOrdersByStatus @StatusID = 5;  -- compiles plan for 100 rows

-- Second call: common status (8 million rows) — reuses the seek+lookup plan!
EXEC dbo.GetOrdersByStatus @StatusID = 1;  -- disaster

The cached plan lives in the plan cache until it is evicted (memory pressure, explicit flush, or ALTER PROCEDURE). Every subsequent call reuses it regardless of what parameter is passed.

When Sniffing Goes Wrong

Sniffing causes problems specifically when:

  • Data is skewed — a small fraction of parameter values account for most rows (status codes, country codes, category IDs)
  • The first compilation uses an extreme value — a rarely-called value produces a hyper-specific plan (index seek) that is wrong for the majority of calls
  • The plan was compiled during a maintenance window — bulk load or reindex flushed the plan cache; the first post-maintenance call compiled with an atypical value
  • Statistics were updated mid-day — an auto-update triggered during peak hours caused a recompile with whatever parameters were active at that moment
Sniffing is not always bad: For uniform distributions where all parameter values return similar row counts, sniffing is a pure win — the cached plan is appropriate for everyone.

Classic Scenario: Skewed Distribution

Consider an Orders table with a StatusID column distribution:

StatusID  Description      Row Count   % of Table
--------  ---------------  ----------  ----------
1         Completed        45,000,000    90%
2         Processing        4,000,000     8%
3         Pending             900,000     1.8%
4         Cancelled            90,000     0.18%
5         Refunded              10,000     0.02%

A plan compiled for StatusID = 5 (10,000 rows) will use an index seek + key lookup for a handful of rows. When that plan is reused for StatusID = 1 (45 million rows), SQL Server performs 45 million key lookups — each requiring a separate I/O into the clustered index. This is thousands of times slower than a clustered index scan would have been.

Conversely, a plan compiled for StatusID = 1 will choose a full table scan. When reused for StatusID = 5 it scans 50 million rows to return 10,000 — also terrible.

Diagnosing with DMVs

The plan cache DMVs let you find procedures with multiple plans (sign of recompile churn) or unusually high CPU/IO relative to execution count (sign of a bad plan):

-- Find cached plans with high average CPU — potential bad sniff
SELECT TOP 20
    OBJECT_NAME(qp.objectid)            AS proc_name,
    qs.execution_count,
    qs.total_worker_time / qs.execution_count   AS avg_cpu_us,
    qs.total_logical_reads / qs.execution_count AS avg_logical_reads,
    qs.total_elapsed_time / qs.execution_count  AS avg_elapsed_us,
    qs.plan_generation_num,
    CAST(qp.query_plan AS NVARCHAR(MAX))        AS plan_xml
FROM sys.dm_exec_procedure_stats qs
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE OBJECT_NAME(qp.objectid) IS NOT NULL
ORDER BY avg_logical_reads DESC;
-- Find the sniffed parameter values stored in the plan
SELECT
    OBJECT_NAME(qs.object_id)   AS proc_name,
    qp.query_plan,              -- look for  section
    qs.execution_count,
    qs.total_logical_reads
FROM sys.dm_exec_procedure_stats qs
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE OBJECT_NAME(qs.object_id) = 'GetOrdersByStatus';

In the XML plan, look for the <ParameterList> node. It shows both ParameterCompiledValue (the sniffed value) and ParameterRuntimeValue (what the current call passed). When these differ significantly, you have a sniffing problem.

Diagnosing with Query Store

Query Store (SQL Server 2016+) makes sniffing diagnosis much easier — it tracks multiple plans per query and their runtime metrics:

-- Queries with multiple plans (parameter sniffing candidate)
SELECT
    q.query_id,
    qt.query_sql_text,
    COUNT(p.plan_id)                         AS plan_count,
    MAX(rs.avg_duration)                     AS max_avg_duration_us,
    MIN(rs.avg_duration)                     AS min_avg_duration_us,
    MAX(rs.avg_duration) / NULLIF(MIN(rs.avg_duration),0) AS duration_ratio
FROM sys.query_store_query q
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan p ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
GROUP BY q.query_id, qt.query_sql_text
HAVING COUNT(p.plan_id) > 1
   AND MAX(rs.avg_duration) / NULLIF(MIN(rs.avg_duration),0) > 10
ORDER BY duration_ratio DESC;

A duration_ratio greater than 10 means one plan variant runs 10× slower than another — classic sniffing signature. In SSMS, the Query Store "Regressed Queries" report shows this visually without writing SQL.

Reading the Execution Plan for Sniffing Evidence

When you capture an actual execution plan, right-click any operator and select Properties. Look for:

  • Estimated Number of Rows — dramatically different from Actual Number of Rows (e.g., estimated 100, actual 4,500,000)
  • Parameter List at the SELECT operator — shows compiled value vs runtime value
  • Fat arrows — wide arrows between operators mean more rows than expected are flowing
  • Key Lookup with thick warning triangle — lookup on millions of rows is a sniffing red flag
In SSMS: View → Query Store → Top Resource Consuming Queries lets you visually compare plans for the same query ID. The "Plan Summary" pane shows estimated vs actual rows per plan.

Fix 1: OPTION (RECOMPILE)

Adding OPTION (RECOMPILE) to the query forces a fresh compile on every execution. SQL Server sniffs the current parameter values, produces an optimal plan, executes it, then discards it. Nothing is cached.

CREATE OR ALTER PROCEDURE dbo.GetOrdersByStatus
    @StatusID INT
AS
    SELECT OrderID, CustomerID, Total, OrderDate
    FROM dbo.Orders
    WHERE StatusID = @StatusID
    ORDER BY OrderDate DESC
    OPTION (RECOMPILE);  -- compile fresh each time

When to use: Queries called infrequently (< a few times per second) with highly variable parameter selectivity. The compile overhead (~1–5 ms for a simple query) is acceptable when it prevents a 30-second scan.

Do not use RECOMPILE on high-frequency OLTP queries. At 1,000 executions/second the compile overhead becomes significant CPU load. For those, use OPTIMIZE FOR or Query Store plan forcing instead.

Fix 2: OPTION (OPTIMIZE FOR)

OPTIMIZE FOR tells the optimizer to compile the plan as if a specific value (or UNKNOWN) were passed — regardless of what is actually supplied:

-- Compile as if @StatusID = 1 (the most common value)
SELECT OrderID, CustomerID, Total, OrderDate
FROM dbo.Orders
WHERE StatusID = @StatusID
ORDER BY OrderDate DESC
OPTION (OPTIMIZE FOR (@StatusID = 1));

-- Compile using average statistics (density) rather than a specific value
SELECT OrderID, CustomerID, Total, OrderDate
FROM dbo.Orders
WHERE StatusID = @StatusID
ORDER BY OrderDate DESC
OPTION (OPTIMIZE FOR (@StatusID UNKNOWN));

OPTIMIZE FOR UNKNOWN forces the optimizer to use the column's average density from the density vector rather than the sniffed histogram value. This produces a "middle ground" plan — not optimal for any single value, but acceptable for the full range.

When to use OPTIMIZE FOR UNKNOWN: When no single "typical" value exists and the distribution is broad. When a specific typical value is known, OPTIMIZE FOR (@p = value) is better — it produces a plan tuned for the majority case.

Fix 3: Local Variables

SQL Server cannot sniff local variables — it has no way to inspect their values at compile time. Assigning a parameter to a local variable forces the optimizer to use average density statistics, identical to OPTIMIZE FOR UNKNOWN:

CREATE OR ALTER PROCEDURE dbo.GetOrdersByStatus
    @StatusID INT
AS
    DECLARE @LocalStatusID INT = @StatusID;  -- sniffing blocked

    SELECT OrderID, CustomerID, Total, OrderDate
    FROM dbo.Orders
    WHERE StatusID = @LocalStatusID
    ORDER BY OrderDate DESC;

This is a simple workaround but has the same trade-off as OPTIMIZE FOR UNKNOWN: the plan is generic, not optimal. Use it only when you cannot modify query hints (e.g., ORM-generated SQL that you can wrap in a procedure).

Fix 4: Multiple Stored Procedures

For two clearly distinct workload types — say, "small result" (rare statuses) and "large result" (common statuses) — two separate procedures let each have its own optimal cached plan:

-- For rare statuses (seek + lookup plan)
CREATE OR ALTER PROCEDURE dbo.GetOrdersByStatus_Small
    @StatusID INT
AS
    SELECT OrderID, CustomerID, Total, OrderDate
    FROM dbo.Orders
    WHERE StatusID = @StatusID
    ORDER BY OrderDate DESC;

-- For common statuses (scan plan)
CREATE OR ALTER PROCEDURE dbo.GetOrdersByStatus_Large
    @StatusID INT
AS
    SELECT OrderID, CustomerID, Total, OrderDate
    FROM dbo.Orders WITH (FORCESEEK)  -- or a different index hint
    WHERE StatusID = @StatusID
    ORDER BY OrderDate DESC;

-- Router procedure decides which to call
CREATE OR ALTER PROCEDURE dbo.GetOrdersByStatus
    @StatusID INT
AS
    IF @StatusID IN (1, 2)
        EXEC dbo.GetOrdersByStatus_Large @StatusID;
    ELSE
        EXEC dbo.GetOrdersByStatus_Small @StatusID;

This is the most explicit solution and produces genuinely optimal plans. The downside is added maintenance complexity.

Fix 5: Plan Guide

A plan guide lets you attach query hints to a query without modifying the source code — useful when the query comes from an ORM or third-party application:

-- Force RECOMPILE on a specific query text
EXEC sp_create_plan_guide
    @name = N'PG_GetOrdersByStatus',
    @stmt = N'SELECT OrderID, CustomerID, Total, OrderDate
              FROM dbo.Orders
              WHERE StatusID = @StatusID
              ORDER BY OrderDate DESC',
    @type = N'OBJECT',
    @module_or_batch = N'dbo.GetOrdersByStatus',
    @params = NULL,
    @hints = N'OPTION (RECOMPILE)';

-- Verify
SELECT name, scope_type_desc, hints, is_disabled
FROM sys.plan_guides
WHERE name = 'PG_GetOrdersByStatus';

Query Store: Force a Good Plan

If you identify a known-good plan in Query Store, you can force SQL Server to always use it regardless of what parameters are passed:

-- Find the good plan_id for a query
SELECT p.plan_id, p.query_id, rs.avg_duration, rs.count_executions
FROM sys.query_store_plan p
JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
WHERE p.query_id = 42  -- replace with your query_id
ORDER BY rs.avg_duration;

-- Force the fastest plan
EXEC sys.sp_query_store_force_plan @query_id = 42, @plan_id = 101;

-- Unforce when no longer needed
EXEC sys.sp_query_store_unforce_plan @query_id = 42, @plan_id = 101;

Forced plans persist across server restarts. They are the production-safe way to pin a known-good plan without touching application code.

Disabling Sniffing: DISABLE_PARAMETER_SNIFFING

SQL Server 2019+ and Azure SQL Database support a database-scoped configuration to disable sniffing globally or per-query:

-- Database level (use with caution)
ALTER DATABASE SCOPED CONFIGURATION SET PARAMETER_SNIFFING = OFF;

-- Per-query hint (SQL Server 2019+)
SELECT OrderID, CustomerID, Total, OrderDate
FROM dbo.Orders
WHERE StatusID = @StatusID
OPTION (USE HINT('DISABLE_PARAMETER_SNIFFING'));
Database-level disable is rarely the right answer. For most queries sniffing is beneficial. Disabling it globally forces the optimizer to use average density for every parameterized query, degrading performance across the board. Use per-query hints or Query Store instead.

Decision Matrix: Which Fix to Use

ScenarioBest Fix
Low-frequency query, highly variable selectivityOPTION (RECOMPILE)
High-frequency OLTP, one dominant typical valueOPTIMIZE FOR (typical value)
High-frequency OLTP, no single typical valueOPTIMIZE FOR UNKNOWN or local variables
Two very distinct workload types (small/large)Split into two procedures
Third-party app, cannot change SQLPlan guide
Need to pin a known-good plan immediatelyQuery Store plan forcing

Read Next

← SQL Server Hub