Senior 3 min · March 14, 2026

SQL Stored Procedures: Parameter Sniffing - 10s to 4 Min

Parameter sniffing: a 500,000-order test account cached a table-scan plan, causing 4-minute waits for normal users.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • A stored procedure is a named, precompiled SQL block stored in the database — called by name, not by sending SQL text each time
  • Benefits: precompiled execution plan, reduced network round-trips, centralised data logic, database-level security control
  • Parameter sniffing: the plan cached on first call may be suboptimal for different parameter values on later calls
  • Dynamic SQL inside procedures re-introduces injection risk — always use sp_executesql with parameters, never string concatenation
  • SQL Server: avoid the sp_ prefix — it forces a master database lookup before checking the current database
  • Use procedures for complex multi-step logic; use ORMs for straightforward CRUD — do not over-engineer simple operations
Plain-English First

A stored procedure is like a saved recipe in the restaurant's official cookbook. Instead of every waiter describing the dish to the chef from scratch each time ('take two eggs, add flour, heat at 180...'), they just say 'make the carbonara'. The chef knows the recipe, has the tools ready, and executes it faster because the prep work was done in advance. Stored procedures work the same way — the database has the query precompiled and ready, so calling it is faster than sending raw SQL every time.

Every production database eventually hits the same wall: raw SQL queries scattered across application code become a maintenance nightmare and a security liability. Stored procedures solve this by separating the definition of data logic from its invocation. By encapsulating logic inside the database, you get precompiled execution plans, centralised maintenance, and database-level permission control.

However, stored procedures are not a universal solution. They couple business logic to the database layer, complicate version control and deployment pipelines, and introduce parameter sniffing problems that can make performance unpredictable. Understanding when to use them — and when to let your ORM handle the job — is a skill that separates pragmatic engineers from those who over-engineer or under-engineer their data layer.

Anatomy of a Production-Ready Stored Procedure

A robust stored procedure handles more than just running a query. It validates inputs, wraps operations in a transaction, handles errors explicitly, and returns meaningful result codes to the caller. The pattern below is a standard template for secure, transactional procedures.

SET NOCOUNT ON at the top suppresses the 'N rows affected' messages that can interfere with application code that checks rowcounts. BEGIN TRY / BEGIN CATCH provides structured error handling — without it, a runtime error leaves the transaction open and the caller gets an obscure error code. XACT_STATE() inside the catch checks whether the transaction is still committable before attempting a rollback.

update_inventory_procedure.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CREATE PROCEDURE sp_UpdateProductStock
    @ProductID INT,
    @QuantityAdjustment INT,
    @UserID INT
AS
BEGIN
    SET NOCOUNT ON;
    
    BEGIN TRY
        BEGIN TRANSACTION;

        -- Update stock level
        UPDATE Products 
        SET StockLevel = StockLevel + @QuantityAdjustment,
            LastUpdatedBy = @UserID
        WHERE ProductID = @ProductID;

        -- Log the movement
        INSERT INTO InventoryAudit (ProductID, ChangeAmount, ChangedBy, LogDate)
        VALUES (@ProductID, @QuantityAdjustment, @UserID, GETDATE());

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
        -- Raise error to the calling application
        THROW;
    END CATCH
END;
Output
-- Successful call:
EXEC usp_UpdateProductStock @ProductID=42, @QuantityChange=-5, @UserID=101;
-- Return value: 0
-- Insufficient stock:
EXEC usp_UpdateProductStock @ProductID=42, @QuantityChange=-9999, @UserID=101;
-- Msg 50000: Insufficient stock: cannot reduce below zero.
-- Return value: -1
Never Use the sp_ Prefix in SQL Server
Naming a procedure sp_UpdateStock forces SQL Server to search the master database for the procedure before checking the current database. This adds overhead on every call and can cause confusing conflicts if a system procedure with the same name exists. Use usp_ (user stored procedure) or a schema prefix like dbo.UpdateStock.
Production Insight
SET NOCOUNT ON is non-optional in production procedures — without it, every DML statement generates a row-count message that can break ADO.NET and some ORMs that interpret unexpected rowcounts as errors.
XACT_STATE() in the CATCH block is critical — if the transaction is in an uncommittable state (-1), you cannot commit, you can only roll back. Missing this check causes silent data integrity failures.
Always test procedures with both success and failure paths — most procedure bugs live in the error handling, not the happy path.
Key Takeaway
SET NOCOUNT ON, BEGIN TRY/CATCH, and XACT_STATE() are the three non-negotiable elements of a production-ready stored procedure.
Always validate inputs before touching data — let the procedure enforce business rules, not the application.
Return explicit codes (0 for success, negative for failure) — callers need deterministic signals, not just exception messages.

Parameter Sniffing — The Silent Performance Trap

When SQL Server first executes a stored procedure, it compiles an execution plan optimised for the specific parameter values provided on that call. This plan is then cached and reused for all subsequent calls, regardless of whether the new parameters would benefit from a different plan. This is parameter sniffing.

For most procedures, sniffing is beneficial — the compiled plan is reused without the overhead of recompilation. The problem occurs when the data distribution is highly variable. A parameter value that returns 5 rows might benefit from an index seek. A parameter value that returns 5 million rows might benefit from a table scan. When the cached plan is optimised for one extreme, it performs terribly for the other.

The three main fixes: WITH RECOMPILE forces a fresh plan on every call (eliminates the benefit of caching but prevents stale plans). OPTION(OPTIMIZE FOR UNKNOWN) generates a plan based on column statistics rather than the sniffed parameter value — a compromise. Local variables instead of direct parameters prevent sniffing entirely by breaking the parameter-value link.

parameter_sniffing_fixes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
-- PROBLEM: Plan cached for small customer gets reused for large customer
CREATE PROCEDURE usp_GetCustomerOrders
    @CustomerID INT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT order_id, total, created_at
    FROM orders
    WHERE customer_id = @CustomerID
    ORDER BY created_at DESC;
END;

-- FIX 1: WITH RECOMPILE -- generates fresh plan every call
-- Use when data distribution is highly variable and recompilation cost is acceptable
ALTER PROCEDURE usp_GetCustomerOrders
    @CustomerID INT
WITH RECOMPILE
AS
BEGIN
    SET NOCOUNT ON;
    SELECT order_id, total, created_at
    FROM orders
    WHERE customer_id = @CustomerID
    ORDER BY created_at DESC;
END;

-- FIX 2: OPTION(OPTIMIZE FOR UNKNOWN) -- plan uses statistics, not sniffed value
ALTER PROCEDURE usp_GetCustomerOrders
    @CustomerID INT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT order_id, total, created_at
    FROM orders
    WHERE customer_id = @CustomerID
    ORDER BY created_at DESC
    OPTION(OPTIMIZE FOR (@CustomerID UNKNOWN));
END;

-- FIX 3: Local variable -- breaks the parameter-value link
ALTER PROCEDURE usp_GetCustomerOrders
    @CustomerID INT
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @LocalID INT = @CustomerID;  -- breaks sniffing
    SELECT order_id, total, created_at
    FROM orders
    WHERE customer_id = @LocalID
    ORDER BY created_at DESC;
END;

-- Monitor procedure stats to detect sniffing problems early
SELECT
    OBJECT_NAME(object_id)  AS procedure_name,
    execution_count,
    total_elapsed_time / execution_count AS avg_elapsed_us,
    total_logical_reads   / execution_count AS avg_logical_reads
FROM sys.dm_exec_procedure_stats
WHERE OBJECT_NAME(object_id) = 'usp_GetCustomerOrders';
Output
-- Before fix: avg_elapsed_us swings between 8,000 and 240,000
-- After OPTIMIZE FOR UNKNOWN: avg_elapsed_us stable at ~45,000
-- Slight degradation for simple calls, major improvement for complex ones
Parameter Sniffing is Beneficial Most of the Time
Do not add WITH RECOMPILE to every procedure as a default. Recompilation adds CPU overhead, especially for complex procedures called thousands of times per second. Only apply sniffing fixes to procedures where you have observed performance variance — monitor sys.dm_exec_procedure_stats to identify candidates.
Production Insight
Parameter sniffing problems are intermittent and self-healing (until the plan is sniffed again) — they look like random performance issues and are notoriously hard to diagnose without knowing to look for them.
The fastest emergency fix: EXEC sp_recompile 'usp_ProcedureName' forces recompilation on the next call without clearing the entire plan cache.
Monitor sys.dm_exec_procedure_stats — large variance in total_elapsed_time / execution_count on the same procedure is a sniffing signal.
Key Takeaway
Parameter sniffing caches the first plan — if first-call parameters are atypical, every subsequent call suffers.
OPTION(OPTIMIZE FOR UNKNOWN) is the safest fix — generates a statistics-based plan rather than a parameter-specific one.
Monitor sys.dm_exec_procedure_stats weekly — high variance in avg_elapsed_time is the sniffing signal.

When to Use Stored Procedures vs ORMs vs Inline SQL

Stored procedures are not always the right tool. The choice depends on the complexity of the logic, your team's SQL proficiency, deployment pipeline maturity, and whether you need database-level security.

Stored procedures are the right choice when: you need to grant users or services access to specific operations without exposing the underlying tables (database-level security); the logic involves multiple steps that must be atomic and the business rules are unlikely to change frequently; performance is critical and you want precompiled plans with minimal network overhead; or you are working in a corporate environment where database changes go through a DBA-controlled deployment process.

ORMs are the right choice for: straightforward CRUD operations where the ORM generates correct, parameterised SQL; when rapid iteration is needed and SQL refactoring via migrations is part of your workflow; teams where SQL expertise is limited and the ORM abstraction reduces errors.

Inline parameterised SQL is appropriate for: complex queries that are unique to one endpoint and do not warrant a named procedure; when the query logic needs to live with the application code for maintainability.

procedure_vs_orm_decision.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- USE STORED PROCEDURE: multi-step atomic operation with business rules
-- Cannot easily express this in a single ORM call
CREATE PROCEDURE usp_PlaceOrder
    @CustomerID INT,
    @ProductID  INT,
    @Quantity   INT
AS
BEGIN
    SET NOCOUNT ON;
    BEGIN TRY
        BEGIN TRANSACTION;

        -- 1. Check inventory
        DECLARE @Stock INT;
        SELECT @Stock = quantity FROM products WHERE product_id = @ProductID;
        IF @Stock < @Quantity
            RAISERROR('Insufficient stock', 16, 1);

        -- 2. Deduct inventory
        UPDATE products
        SET quantity = quantity - @Quantity
        WHERE product_id = @ProductID;

        -- 3. Create order
        INSERT INTO orders (customer_id, product_id, quantity, created_at)
        VALUES (@CustomerID, @ProductID, @Quantity, GETUTCDATE());

        -- 4. Update customer lifetime value
        UPDATE customers
        SET lifetime_orders = lifetime_orders + 1
        WHERE customer_id = @CustomerID;

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
        THROW;
    END CATCH;
END;

-- USE ORM: simple CRUD -- no business logic, let the framework handle it
-- Python SQLAlchemy example:
-- product = db.query(Product).filter_by(id=product_id).first()
-- db.add(Order(customer_id=cid, product_id=product_id, quantity=qty))
-- db.commit()
-- This is cleaner and more maintainable for straightforward operations
Output
-- Procedure call:
EXEC usp_PlaceOrder @CustomerID=42, @ProductID=7, @Quantity=3;
-- Atomically: checks stock, deducts inventory, creates order, updates customer
-- All or nothing -- no partial state possible
The Deployment Pipeline Test
Before putting logic in a stored procedure, ask: can I version-control, test, and deploy this the same way I deploy application code? If the answer is no — if procedure changes require a DBA ticket and a separate deployment window — consider whether the logic belongs in the application layer instead.
Production Insight
Stored procedures couple business logic to the database — when the logic needs to change, you need a database deployment, not just an application deployment.
For teams using continuous deployment, stored procedures should be managed as database migrations (Flyway, Liquibase, Alembic) — not manually applied DDL.
The security grant pattern is the strongest argument for procedures: GRANT EXECUTE ON usp_PlaceOrder TO app_user gives controlled access without exposing the underlying tables.
Key Takeaway
Use procedures for multi-step atomic operations, security control, and logic that changes infrequently.
Use ORMs for straightforward CRUD where the generated SQL is correct and deployment agility matters.
Manage procedures as migrations — manual DDL applied outside version control is a production liability.
● Production incidentPOST-MORTEMseverity: high

Parameter Sniffing Caused a 10-Second Stored Procedure to Randomly Take 4 Minutes

Symptom
Customer service dashboards periodically became unusable. The stored procedure GetCustomerOrders would spike to 4+ minutes. Restarting the application had no effect. Restarting the SQL Server — or just running EXEC sp_recompile GetCustomerOrders — fixed it instantly.
Assumption
The procedure had been tested with typical customer IDs. Performance looked fine. The team assumed a consistent execution plan was always generated.
Root cause
Parameter sniffing: SQL Server compiles a procedure's execution plan on first call and caches it, optimised for the parameter values used on that call. The first call on Monday morning happened to use a customer ID with 500,000 orders (a test/demo account). SQL Server generated a plan using table scans, which is appropriate for that volume. When normal users with 5-50 orders called the same procedure, their calls reused the table-scan plan instead of generating an index-scan plan optimised for small result sets. Performance was catastrophic for the entire week until the cache was evicted.
Fix
Added WITH RECOMPILE to the procedure to generate a fresh plan on each call. For procedures with highly variable parameter distributions, OPTION(OPTIMIZE FOR UNKNOWN) was added to force a generic plan rather than a parameter-specific one.
Key lesson
  • Parameter sniffing is silent — the procedure appears to work correctly until an atypical parameter value poisons the cached plan
  • Use WITH RECOMPILE for procedures with highly variable parameter distributions (e.g., customer IDs spanning 5 to 500,000 rows)
  • Monitor procedure execution statistics with sys.dm_exec_procedure_stats to detect plan cache issues proactively
Production debug guideDiagnosing performance and correctness issues in stored procedures4 entries
Symptom · 01
Procedure runs fast sometimes and slow other times with the same logic
Fix
Parameter sniffing. Check the cached plan: SELECT * FROM sys.dm_exec_cached_plans CROSS APPLY sys.dm_exec_sql_text(plan_handle) WHERE text LIKE '%YourProcedureName%'. Clear the specific plan: DBCC FREEPROCCACHE(plan_handle). Add WITH RECOMPILE or OPTION(OPTIMIZE FOR UNKNOWN) to the procedure.
Symptom · 02
Procedure with dynamic SQL is slow or has injection concerns
Fix
Replace string concatenation with sp_executesql: EXEC sp_executesql N'SELECT * FROM orders WHERE status = @s', N'@s VARCHAR(50)', @s = @status_param. Always parameterise dynamic SQL — never concatenate user-controlled values into the SQL string.
Symptom · 03
Procedure is inconsistently slow on SQL Server despite no code changes
Fix
Check if the procedure name has the sp_ prefix — this forces a master database lookup before checking the current database, adding overhead. Rename to usp_ or a custom prefix. Verify with sys.dm_exec_procedure_stats that the avg_elapsed_time has changed.
Symptom · 04
Procedure modifies data but changes are not persisted — row counts show success
Fix
Check SET NOCOUNT ON — it suppresses row-count messages but does not affect commits. Check if the procedure is called within an outer transaction that is being rolled back. Verify AUTOCOMMIT settings for the connection.
ApproachPerformanceSecurity controlTestabilityBest for
Stored ProcedureBest — precompiled planDatabase-level GRANT EXECUTEHarder — requires DB stateComplex multi-step logic, security isolation
ORMGood — parameterisedApplication-levelEasy — unit testableCRUD operations, rapid iteration
Inline parameterised SQLGoodApplication-levelEasyComplex one-off queries close to application code
Raw string queryVariesNone — injection riskFragileNever use in production

Key takeaways

1
SET NOCOUNT ON, BEGIN TRY/CATCH, and XACT_STATE() are non-negotiable in every production stored procedure.
2
Parameter sniffing caches the first call's plan
monitor sys.dm_exec_procedure_stats for variance and use OPTION(OPTIMIZE FOR UNKNOWN) as the safest fix.
3
The sp_ prefix in SQL Server forces a master database lookup
always use usp_ or schema-qualified names.
4
Stored procedures are right for complex multi-step atomic operations and security isolation; ORMs are right for CRUD
do not use one tool for everything.

Common mistakes to avoid

3 patterns
×

Using the sp_ prefix for custom stored procedures in SQL Server

Symptom
Procedure calls have measurable overhead; occasionally a procedure fails with confusing errors when a system procedure has the same name
Fix
Rename to usp_ or a schema-qualified name like dbo.UpdateStock. The sp_ prefix forces SQL Server to search the master database before the current database on every execution.
×

Ignoring parameter sniffing for procedures with variable data distributions

Symptom
Procedure randomly takes 10x longer for some calls than others with no observable difference in input data
Fix
Add OPTION(OPTIMIZE FOR UNKNOWN) to queries inside the procedure for a statistics-based plan. For procedures where recompilation overhead is acceptable, add WITH RECOMPILE. Monitor sys.dm_exec_procedure_stats to detect the pattern early.
×

Using dynamic SQL with string concatenation inside a stored procedure

Symptom
The procedure is vulnerable to SQL injection even though it is called via a stored procedure — a security scanner flags it; or the procedure fails with syntax errors on unexpected input
Fix
Use sp_executesql with explicit parameter declarations: EXEC sp_executesql @sql, N'@param NVARCHAR(50)', @param = @value. Never concatenate user-controlled values into a dynamic SQL string inside a procedure.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is parameter sniffing in SQL Server and how do you fix it?
Q02SENIOR
What is the difference between a stored procedure and a function in SQL?
Q03SENIOR
When would you choose a stored procedure over an ORM query, and vice ver...
Q01 of 03SENIOR

What is parameter sniffing in SQL Server and how do you fix it?

ANSWER
Parameter sniffing is SQL Server's behaviour of compiling and caching a stored procedure's execution plan based on the parameter values provided on the first execution. This plan is then reused for all subsequent calls. The problem occurs when data distribution is highly variable — a plan optimised for a parameter that returns 10 rows (using index seeks) will perform terribly for a parameter that returns 10 million rows (where a table scan would be better), and vice versa. Three fixes: WITH RECOMPILE on the procedure generates a fresh plan on every call — eliminates caching benefits but ensures plan accuracy. OPTION(OPTIMIZE FOR UNKNOWN) on the query generates a plan based on column statistics rather than the sniffed value. Assigning the parameter to a local variable before using it in the query breaks the sniffing link. Monitor sys.dm_exec_procedure_stats for high variance in avg_elapsed_time as the diagnostic signal.
FAQ · 2 QUESTIONS

Frequently Asked Questions

01
Do stored procedures prevent SQL injection?
02
Are stored procedures faster than inline SQL?
🔥

That's SQL Advanced. Mark it forged?

3 min read · try the examples if you haven't

Previous
SQL Views
3 / 16 · SQL Advanced
Next
SQL Triggers