MS SQL Lesson 33 – Stored Procedures | Dataplexa

Stored Procedures

A stored procedure is a named, compiled, and stored T-SQL program that lives inside the database. You write it once, give it a name, and call it by that name — with parameters if needed — from any application, script, or other procedure that has permission to execute it. Stored procedures serve three distinct purposes that justify their use over sending raw SQL from application code: they centralise business logic at the database level so that every application consuming the data uses the same validated rules, they improve performance because SQL Server compiles and caches execution plans on first execution and reuses them on subsequent calls, and they enforce security by letting you grant EXECUTE permission on a procedure without granting direct SELECT, INSERT, UPDATE, or DELETE permission on the underlying tables. This lesson covers creating, modifying, and managing stored procedures in full — parameters, output values, error handling, return codes, and the key behaviours that distinguish well-written procedures from fragile ones.

Creating a Basic Stored Procedure

The CREATE PROCEDURE statement defines the procedure name and body. The body is any valid T-SQL — SELECT, INSERT, UPDATE, DELETE, control flow, transactions, or any combination. A procedure without parameters is essentially a named query. The convention is to prefix procedure names with usp_ (user stored procedure) to distinguish them from system procedures. Once created, a procedure is called with EXEC or EXECUTE followed by the procedure name. Like views, the preferred deployment syntax is CREATE OR ALTER PROCEDURE, which creates if absent or replaces if present — making scripts safely re-runnable.

-- Create a simple stored procedure — product sales summary
USE DataplexaStore;

CREATE OR ALTER PROCEDURE usp_GetProductSalesSummary
AS
BEGIN
    SET NOCOUNT ON;     -- suppresses "n rows affected" messages — cleaner for callers

    SELECT
        p.ProductID,
        p.ProductName,
        p.Category,
        p.Price,
        p.StockQty,
        COUNT(oi.OrderItemID)                   AS times_ordered,
        ISNULL(SUM(oi.Quantity), 0)             AS units_sold,
        ISNULL(SUM(oi.Quantity * oi.UnitPrice), 0) AS revenue
    FROM       Products    p
    LEFT JOIN  OrderItems  oi ON p.ProductID = oi.ProductID
    GROUP BY   p.ProductID, p.ProductName, p.Category, p.Price, p.StockQty
    ORDER BY   revenue DESC;
END;

-- Execute the procedure
EXEC usp_GetProductSalesSummary;

-- Inspect stored procedure definitions
SELECT
    OBJECT_NAME(object_id)      AS procedure_name,
    create_date,
    modify_date
FROM   sys.procedures
WHERE  name LIKE 'usp_%'
ORDER BY name;
ProductID ProductName Category Price StockQty times_ordered units_sold revenue
───────── ──────────────────── ─────────── ────── ──────── ───────────── ────────── ───────
10 Monitor 27 inch Electronics 359.99 10 2 2 719.98
2 Mechanical Keyboard Electronics 81.80 15 2 2 163.60
8 Notebook A5 Stationery 12.99 120 2 8 103.92
5 Desk Lamp Home Office 36.74 55 2 2 73.48
3 USB-C Hub Electronics 45.44 30 2 2 90.88
1 Wireless Mouse Electronics 29.99 42 2 2 59.98
4 Webcam HD Electronics 71.99 18 0 0 0.00
6 Standing Desk Home Office 367.49 8 0 0 0.00
7 Ergonomic Chair Home Office 262.49 12 0 0 0.00
9 Ballpoint Pen Set Stationery 8.54 0 0 0 0.00

-- sys.procedures:
procedure_name create_date modify_date
────────────────────────── ─────────────────────── ───────────────────────
usp_GetProductSalesSummary 2024-03-28 16:00:00.000 2024-03-28 16:00:00.000
  • SET NOCOUNT ON is the first line in almost every stored procedure — without it every INSERT, UPDATE, and DELETE inside the procedure sends a "n rows affected" message to the caller, which clutters output and can interfere with some ORM frameworks that parse result sets
  • The procedure is stored in sys.procedures and its full SQL text is in sys.sql_modules — the same catalog views used for views, making it straightforward to retrieve procedure definitions programmatically
  • Plan caching applies immediately — the first EXEC compiles the plan; subsequent EXECs reuse the cached plan without recompiling, which is one of the core performance benefits of stored procedures over ad-hoc SQL

Input Parameters

Parameters are declared after the procedure name with a name, data type, and optional default value. Input parameters allow callers to pass values into the procedure, making a single procedure serve many use cases — one procedure for all product categories rather than one procedure per category. Parameters with default values are optional at call time — the default is used if the caller omits them. Named parameter syntax (@param = value) is safer than positional syntax because it is unaffected by parameter order changes when the procedure is later modified. Always validate input parameters at the top of the procedure — never trust that the caller has passed a sane value.

-- Stored procedure with input parameters and defaults

CREATE OR ALTER PROCEDURE usp_GetProductsByCategory
    @Category       NVARCHAR(50),
    @MinPrice       DECIMAL(10,2) = 0,          -- optional: default 0 (no lower limit)
    @MaxPrice       DECIMAL(10,2) = 999999,     -- optional: default high (no upper limit)
    @SortBy         NVARCHAR(20)  = 'Price'     -- optional: default sort column
AS
BEGIN
    SET NOCOUNT ON;

    -- Validate parameters
    IF @Category IS NULL OR LEN(TRIM(@Category)) = 0
    BEGIN
        RAISERROR('Category parameter cannot be NULL or empty.', 16, 1);
        RETURN;
    END

    IF @MinPrice < 0 OR @MaxPrice < 0 OR @MinPrice > @MaxPrice
    BEGIN
        RAISERROR('Price range is invalid.', 16, 1);
        RETURN;
    END

    SELECT
        ProductID,
        ProductName,
        Category,
        Price,
        StockQty
    FROM   Products
    WHERE  Category = @Category
      AND  Price BETWEEN @MinPrice AND @MaxPrice
    ORDER BY
        CASE WHEN @SortBy = 'Price'    THEN Price        END ASC,
        CASE WHEN @SortBy = 'Stock'    THEN StockQty     END DESC,
        CASE WHEN @SortBy = 'Name'     THEN ProductName  END ASC;
END;

-- Call with all parameters named
EXEC usp_GetProductsByCategory
    @Category = 'Electronics',
    @MinPrice = 30,
    @MaxPrice = 100,
    @SortBy   = 'Price';

-- Call with only the required parameter — defaults apply
EXEC usp_GetProductsByCategory @Category = 'Home Office';

-- Invalid call — triggers RAISERROR
EXEC usp_GetProductsByCategory @Category = '';
-- Electronics, 30-100, sorted by price:
ProductID ProductName Category Price StockQty
───────── ─────────────────── ─────────── ───── ────────
1 Wireless Mouse Electronics 29.99 42
3 USB-C Hub Electronics 45.44 30
4 Webcam HD Electronics 71.99 18
2 Mechanical Keyboard Electronics 81.80 15

-- Home Office (all prices, default sort):
ProductID ProductName Category Price StockQty
───────── ─────────────── ─────────── ────── ────────
5 Desk Lamp Home Office 36.74 55
7 Ergonomic Chair Home Office 262.49 12
6 Standing Desk Home Office 367.49 8

-- Empty category:
Msg 50000, Level 16, State 1
Category parameter cannot be NULL or empty.
  • Named parameter syntax (@Category = 'Electronics') is always preferred over positional — if a developer later adds a new parameter between @Category and @MinPrice, positional calls silently pass the wrong values to the wrong parameters
  • The CASE expression in ORDER BY is the standard T-SQL pattern for dynamic sorting — a single query handles multiple sort options without dynamic SQL; the limitation is that all CASE branches must return compatible types
  • RAISERROR with severity 16 generates a non-fatal error that the caller can catch — severity 20+ would terminate the connection; 16 is the conventional severity for application-level validation errors

Output Parameters and Return Values

A stored procedure can return data in three ways: a result set (rows from a SELECT), output parameters (values written back to the caller's variables), and a return code (an integer from the RETURN statement). Result sets are for multi-row data. Output parameters are for single scalar values that the caller needs — a generated ID, a calculated total, a status flag. The return code is conventionally used to signal success (0) or a specific error category (non-zero), though TRY...CATCH has largely replaced return codes for error handling. Output parameters are declared with the OUTPUT keyword in both the procedure definition and the EXEC call.

-- Procedure with output parameters — place an order and return the new OrderID and total

CREATE OR ALTER PROCEDURE usp_PlaceOrder
    @UserID         INT,
    @ProductID      INT,
    @Quantity       INT,
    @NewOrderID     INT             OUTPUT,     -- returns the generated OrderID
    @OrderTotal     DECIMAL(10,2)   OUTPUT      -- returns the calculated total
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @UnitPrice  DECIMAL(10,2);
    DECLARE @StockQty   INT;

    -- Validate: product must exist and have sufficient stock
    SELECT @UnitPrice = Price, @StockQty = StockQty
    FROM   Products
    WHERE  ProductID = @ProductID;

    IF @UnitPrice IS NULL
    BEGIN
        RAISERROR('Product %d does not exist.', 16, 1, @ProductID);
        RETURN -1;      -- return code -1: product not found
    END

    IF @StockQty < @Quantity
    BEGIN
        RAISERROR('Insufficient stock. Available: %d, Requested: %d', 16, 1, @StockQty, @Quantity);
        RETURN -2;      -- return code -2: insufficient stock
    END

    BEGIN TRANSACTION;
    BEGIN TRY

        -- Insert the order
        INSERT INTO Orders (UserID, TotalAmount, Status)
        VALUES (@UserID, 0, 'processing');

        SET @NewOrderID = SCOPE_IDENTITY();

        -- Insert the order line
        INSERT INTO OrderItems (OrderID, ProductID, Quantity, UnitPrice)
        VALUES (@NewOrderID, @ProductID, @Quantity, @UnitPrice);

        -- Deduct stock
        UPDATE Products
        SET    StockQty = StockQty - @Quantity
        WHERE  ProductID = @ProductID;

        -- Calculate and update total
        SET @OrderTotal = @Quantity * @UnitPrice;

        UPDATE Orders
        SET    TotalAmount = @OrderTotal
        WHERE  OrderID = @NewOrderID;

        COMMIT TRANSACTION;
        RETURN 0;       -- return code 0: success

    END TRY
    BEGIN CATCH
        IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
        RAISERROR('Order placement failed: %s', 16, 1, ERROR_MESSAGE());
        RETURN -99;
    END CATCH;
END;

-- Call the procedure and capture output parameters
DECLARE @OrderID    INT;
DECLARE @Total      DECIMAL(10,2);
DECLARE @ReturnCode INT;

EXEC @ReturnCode = usp_PlaceOrder
    @UserID     = 3,
    @ProductID  = 8,
    @Quantity   = 2,
    @NewOrderID = @OrderID  OUTPUT,
    @OrderTotal = @Total    OUTPUT;

SELECT
    @ReturnCode AS return_code,
    @OrderID    AS new_order_id,
    @Total      AS order_total;
return_code new_order_id order_total
─────────── ──────────── ───────────
0 8 25.98
  • return_code = 0 confirms success — the calling script can branch on this value: zero means proceed, non-zero means handle the specific failure code
  • @NewOrderID is populated inside the procedure via SCOPE_IDENTITY() and written back to the caller's variable through the OUTPUT parameter — the caller now has the new OrderID without running a second query
  • The transaction wraps all three DML statements — if the stock deduction fails after the order was inserted, the CATCH block rolls back everything; the caller receives RAISERROR with the specific error message and return code -99

Parameter Sniffing and OPTION (RECOMPILE)

When SQL Server compiles a stored procedure it builds a plan based on the parameter values passed on the first execution — this is called parameter sniffing. The plan is cached and reused for all subsequent calls regardless of what parameter values they pass. If the first call used a highly selective value (one row) and built a Nested Loops plan, subsequent calls passing a non-selective value (returning 50,000 rows) reuse the same Nested Loops plan even though a Hash Match would be far better. This is one of the most common and most confusing performance problems with stored procedures. OPTION (RECOMPILE) at the query level forces a fresh plan for every execution — eliminating the stale plan problem at the cost of recompilation overhead on each call.

-- Parameter sniffing demonstration and solutions

-- Procedure with parameter sniffing vulnerability
CREATE OR ALTER PROCEDURE usp_GetOrdersByStatus
    @Status NVARCHAR(20)
AS
BEGIN
    SET NOCOUNT ON;

    -- If first call uses Status = 'processing' (1 row), plan is optimised for 1 row
    -- Later calls with Status = 'delivered' (many rows) reuse the same narrow plan
    SELECT
        o.OrderID,
        o.OrderDate,
        o.TotalAmount,
        u.FullName
    FROM   Orders o
    JOIN   Users  u ON o.UserID = u.UserID
    WHERE  o.Status = @Status
    ORDER BY o.OrderDate DESC;
END;

-- Solution 1: OPTION (RECOMPILE) — fresh plan per execution
CREATE OR ALTER PROCEDURE usp_GetOrdersByStatus_Safe
    @Status NVARCHAR(20)
AS
BEGIN
    SET NOCOUNT ON;

    SELECT
        o.OrderID,
        o.OrderDate,
        o.TotalAmount,
        u.FullName
    FROM   Orders o
    JOIN   Users  u ON o.UserID = u.UserID
    WHERE  o.Status = @Status
    ORDER BY o.OrderDate DESC
    OPTION (RECOMPILE);    -- new plan on every call — correct for highly variable parameters
END;

-- Solution 2: LOCAL variable trick — breaks parameter sniffing by hiding the value
CREATE OR ALTER PROCEDURE usp_GetOrdersByStatus_Local
    @Status NVARCHAR(20)
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @LocalStatus NVARCHAR(20) = @Status;  -- optimiser cannot sniff through locals

    SELECT
        o.OrderID,
        o.OrderDate,
        o.TotalAmount,
        u.FullName
    FROM   Orders o
    JOIN   Users  u ON o.UserID = u.UserID
    WHERE  o.Status = @LocalStatus
    ORDER BY o.OrderDate DESC;
END;

EXEC usp_GetOrdersByStatus_Safe @Status = 'delivered';
OrderID OrderDate TotalAmount FullName
─────── ─────────────────────────── ─────────── ────────────
2 2024-03-18 14:45:00.0000000 512.92 Bob Williams
6 2024-03-28 14:22:31.0000000 80.99 Bob Williams
  • OPTION (RECOMPILE) is the correct choice when parameter values have highly variable selectivity — a Status value that returns 1 row versus one that returns 500,000 rows genuinely needs different plans; the recompilation cost (typically microseconds) is far less than the cost of executing a wrong plan
  • The local variable trick forces the optimiser to build a plan based on average selectivity (derived from statistics density) rather than the specific sniffed value — useful when OPTION (RECOMPILE) overhead is unacceptable on very high-frequency procedures
  • Parameter sniffing is not always bad — when parameter values have similar distributions (most calls return a similar number of rows) the cached plan is appropriate for all calls and plan reuse is a genuine performance benefit

Summary Table

Feature Purpose Key Point
CREATE OR ALTER PROCEDURE Create or replace idempotently Safe to run multiple times — preserves permissions
SET NOCOUNT ON Suppress row-count messages First line in every stored procedure body
Input parameters with defaults Optional caller-supplied values Always use named parameter syntax in EXEC
OUTPUT parameters Return scalar values to caller Declared with OUTPUT in definition and EXEC
RETURN code Signal success or error type 0 = success by convention — non-zero = specific failure
TRY...CATCH + transaction Safe error handling with rollback Check XACT_STATE() before ROLLBACK in CATCH
OPTION (RECOMPILE) Prevent bad cached plan reuse Use when parameter selectivity varies widely

Practice Questions

Practice 1. Why is SET NOCOUNT ON the first line in almost every stored procedure?



Practice 2. What is the difference between an output parameter and a return code in a stored procedure?



Practice 3. Explain parameter sniffing and describe one scenario where it causes a performance problem.



Practice 4. Why is named parameter syntax preferred over positional syntax when calling a stored procedure?



Practice 5. A stored procedure contains three DML statements inside a transaction. The second one fails. With TRY...CATCH and ROLLBACK in the CATCH block, what is the final state?



Quiz

Quiz 1. What is the conventional return code value for a successful stored procedure execution?






Quiz 2. A stored procedure parameter is declared with a default value. What happens when the caller omits that parameter?






Quiz 3. Which system view stores the full SQL definition of a stored procedure?






Quiz 4. OPTION (RECOMPILE) is added to a query inside a stored procedure. What is the trade-off?






Quiz 5. An OUTPUT parameter is declared in a procedure definition but the caller does not include OUTPUT in the EXEC call. What happens?






Next up - User-Defined Functions - Write scalar and table-valued functions that encapsulate reusable calculations and return values directly inside queries.