Stored Procedures in SQL Server: CREATE PROCEDURE, Parameters, and the Traps
Stored Procedures in SQL Server
A stored procedure is a named batch of T-SQL that lives in the database and executes with a cached plan. That plan cache is both the main reason to use procedures and the source of their most famous production problem (parameter sniffing, covered below). This guide covers the syntax, the three ways to get data out, and the handful of traps that account for most stored-procedure bugs.
Creating a Procedure
CREATE OR ALTER PROCEDURE dbo.get_orders_by_region
@region varchar(50),
@min_amount decimal(10,2) = 0 -- default makes it optional
AS
BEGIN
SET NOCOUNT ON;
SELECT order_id, customer_id, amount, ordered_at
FROM dbo.orders
WHERE region = @region
AND amount >= @min_amount;
END;EXEC dbo.get_orders_by_region @region = 'EMEA', @min_amount = 100;Three details worth adopting from day one:
CREATE OR ALTER(SQL Server 2016 SP1+) is idempotent and, unlikeDROP+CREATE, preserves existing permissions on the procedure.- Always name parameters at the call site.
EXEC dbo.get_orders_by_region 'EMEA', 100works, but breaks silently when someone reorders parameters. - Schema-qualify (
dbo.get_orders_by_region, notget_orders_by_region) both when creating and calling -- unqualified calls cost a name resolution and can resolve to the wrong schema.
The sp_ prefix trap
Don't name procedures sp_anything. The sp_ prefix is reserved for system procedures: SQL Server looks up sp_-prefixed names in master first, so your procedure gets a wasted lookup at best and is silently shadowed by a system procedure of the same name at worst. Use a different convention (usp_, or no prefix and rely on schemas).
SET NOCOUNT ON
Put SET NOCOUNT ON at the top of nearly every procedure. Without it, SQL Server sends a DONE_IN_PROC message ("3 rows affected") to the client for every statement. For a procedure that runs a loop or many statements, that's real network chatter, and some ORMs misread the extra messages as result sets. @@ROWCOUNT still works with it on, so you lose nothing.
Getting Data Out: Three Mechanisms
Procedures have three distinct output channels, and mixing them up causes real bugs.
1. Result sets
A SELECT inside the procedure streams a result set to the client. This is the normal way to return query data. A procedure can return multiple result sets; most drivers expose them via a "next result" call (NextResult() in ADO.NET, nextset() in pyodbc).
2. OUTPUT parameters
For returning a handful of scalar values:
CREATE OR ALTER PROCEDURE dbo.create_customer
@name nvarchar(100),
@customer_id int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.customers (name) VALUES (@name);
SET @customer_id = SCOPE_IDENTITY();
END;DECLARE @id int;
EXEC dbo.create_customer @name = N'Acme GmbH', @customer_id = @id OUTPUT;
SELECT @id;The trap: you must write OUTPUT at the call site too. Omit it and the call succeeds silently -- @id just stays NULL. This is a classic "works in testing, NULL in production" bug because nothing errors.
3. Return codes
RETURN n returns a single int, and by convention it's a status code, not data: 0 for success, nonzero for a failure class. Don't use it to smuggle out an ID -- it can't be NULL, it's int only, and every driver treats it as a status. Capture it with:
DECLARE @rc int;
EXEC @rc = dbo.some_procedure @param = 1;Table-Valued Parameters
To pass a set of rows into a procedure (for example, a batch of order lines), use a table-valued parameter. Define a table type once, then take it as a READONLY parameter:
CREATE TYPE dbo.order_line_list AS TABLE (
product_id int NOT NULL,
quantity int NOT NULL,
price decimal(10,2) NOT NULL
);
GO
CREATE OR ALTER PROCEDURE dbo.add_order_lines
@order_id int,
@lines dbo.order_line_list READONLY
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.order_lines (order_id, product_id, quantity, price)
SELECT @order_id, product_id, quantity, price
FROM @lines;
END;TVPs must be declared READONLY -- you can't modify the rows inside the procedure. They're the right answer to "how do I pass a list?"; the wrong answers people reach for first are comma-separated strings (parse with STRING_SPLIT, lose types) and dynamic SQL. Client drivers support TVPs directly (ADO.NET SqlDbType.Structured, pyodbc via list-of-tuples with a typed setup).
For completeness: parameters max out at 2,100 per procedure, which sounds academic until an ORM auto-generates an IN list that hits it. That's another problem TVPs solve.
Parameter Sniffing: The Big One
When a procedure runs for the first time, SQL Server compiles a plan using the parameter values of that first call ("sniffing" them) and caches it. Every later call reuses that plan, whatever its parameters. Usually that's exactly what you want -- it's the point of caching. It goes wrong when parameter values have wildly different selectivities:
-- First call: @region = 'ANTARCTICA' (5 rows) → plan with key lookups
EXEC dbo.get_orders_by_region @region = 'ANTARCTICA';
-- Later: @region = 'EMEA' (40 million rows) → same lookup plan, now catastrophic
EXEC dbo.get_orders_by_region @region = 'EMEA';The symptom: a procedure that's fast for weeks, then suddenly slow for everyone after a restart or plan eviction changed which call compiled the plan. Fixes, in rough order of preference:
| Fix | What it does | Cost |
|---|---|---|
OPTION (RECOMPILE) on the problem statement | Compiles that statement per execution with actual values | CPU per execution; fine for infrequent queries |
OPTION (OPTIMIZE FOR (@p = 'typical')) | Always plans for a representative value | You must know the typical value; skews rare cases |
OPTIMIZE FOR UNKNOWN | Plans for average density, ignores sniffed value | Mediocre-for-everyone plan |
| Branch to separate procedures per case | Each gets its own cached plan | More code to maintain |
| Parameter Sensitive Plan optimization (2022, compat 160) | Caches multiple plans per sniffed bucket | Automatic, but only kicks in for eligible equality predicates |
Diagnose it by comparing estimated vs actual rows in the actual execution plan -- see our execution plans guide for how to read that.
One related anti-pattern: assigning a parameter to a local variable inside the procedure (DECLARE @r varchar(50) = @region) to "fix" sniffing. It works by making the optimizer blind (same as OPTIMIZE FOR UNKNOWN), but it's a hack that hides intent -- use the explicit hint.
Error Handling
Wrap real work in TRY/CATCH, use THROW (not RAISERROR) to rethrow, and set XACT_ABORT ON when the procedure owns a transaction:
CREATE OR ALTER PROCEDURE dbo.transfer_stock
@from_warehouse int, @to_warehouse int, @product_id int, @qty int
AS
BEGIN
SET NOCOUNT, XACT_ABORT ON;
BEGIN TRY
BEGIN TRAN;
UPDATE dbo.stock SET qty = qty - @qty
WHERE warehouse_id = @from_warehouse AND product_id = @product_id;
UPDATE dbo.stock SET qty = qty + @qty
WHERE warehouse_id = @to_warehouse AND product_id = @product_id;
COMMIT;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK;
THROW; -- rethrows original error, preserves the real line number
END CATCH
END;
Why this shape matters: without `XACT_ABORT ON`, many runtime errors abort only the current statement and leave the transaction half-done and still open. And a `CATCH` block that rolls back but swallows the error (no `THROW`) turns failures into silent no-ops. The full story is in our [transactions guide](/guides/mssql/transactions).
Procedures can call procedures up to 32 nesting levels deep (`@@NESTLEVEL` tells you where you are). If you're anywhere near that limit, the design has already gone wrong.
## When Not to Use a Stored Procedure
Honest scoping, because "put everything in procs" and "never use procs" are both dogma:
- **Single static queries an app runs**: parameterized queries from the app get the same plan caching. A procedure adds a deployment surface without adding value.
- **Business logic that changes weekly**: T-SQL is harder to test, version, and code-review than application code. Procedures earn their keep for set-based data logic close to the data, not for rules engines.
- **Dynamic search screens** ("filter by any combination of 12 fields"): a procedure with 12 optional parameters is a parameter-sniffing generator. Either build the SQL dynamically with `sp_executesql` and proper parameters, or accept `OPTION (RECOMPILE)`.
Where procedures genuinely win: multi-statement transactional units (the stock transfer above), batch operations over TVPs, a security boundary (grant `EXECUTE` on the proc, no table access), and keeping chatty multi-step logic on the server side of the network.
## Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Forgetting `OUTPUT` at the call site | Output variable silently `NULL` | Write `OUTPUT` in both places |
| `sp_` prefix | Shadowed by/confused with system procs | Different prefix or schema-based naming |
| Missing `SET NOCOUNT ON` | ORM confusion, extra network messages | First line of the procedure |
| Using `RETURN` to pass data | Truncated to `int`, breaks conventions | `OUTPUT` parameter or result set |
| Local-variable copy of a parameter | Optimizer plans blind, hides intent | `OPTION (OPTIMIZE FOR ...)` explicitly |
| One giant proc with optional params | Random slowness (sniffing) | Recompile hint, or split per case |
| `CATCH` without `THROW` | Errors vanish, callers think it worked | Always rethrow unless genuinely handled |
Writing and iterating on procedure bodies means a lot of exploratory `SELECT`s against the tables involved; Mako's AI autocomplete is useful for sketching those queries before you freeze them into a procedure.
Mako connects to SQL Server with AI-powered autocomplete. Try it free at mako.ai.Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.