Skip to main content
ANVISoftware Solutions
Lesson 14 of 22Intermediate18 min

Stored Procedures

By the end of this lesson

Encapsulate logic in the database, weighing the trade-offs.

A stored procedure is a named block of SQL statements, saved in the database, that you run by name and can pass parameters to.

Unlike a view, it can contain several statements, variables, conditions, loops and transactions. That makes it capable of holding real logic — which is both the appeal and the thing to be careful about.

A procedure with input parameters
SQL
CREATE OR ALTER PROCEDURE usp_get_customer_orders
    @customer_id INT,
    @from_date   DATE,
    @to_date     DATE
AS
BEGIN
    SET NOCOUNT ON;

    SELECT
        o.order_id,
        o.order_date,
        o.status,
        SUM(oi.quantity * oi.unit_price) + o.shipping_fee AS order_total
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.order_id
    WHERE o.customer_id = @customer_id
      AND o.order_date >= @from_date
      AND o.order_date <  @to_date
    GROUP BY o.order_id, o.order_date, o.status, o.shipping_fee
    ORDER BY o.order_date DESC;
END;

-- Running it
EXEC usp_get_customer_orders
    @customer_id = 417,
    @from_date   = '2026-01-01',
    @to_date     = '2027-01-01';
  • Parameters are declared with a type, like columns. They are passed as values, which means the database treats them as data and never as part of the SQL text — that property is why parameters are the defence against injection, covered in the security lesson.
  • CREATE OR ALTER creates the procedure or replaces it if it exists, which suits a script you run repeatedly. It is supported from SQL Server 2016 SP1; earlier versions need a DROP then CREATE.
  • SET NOCOUNT ON suppresses the "n rows affected" messages. On a procedure called frequently from an application, those messages are pure overhead.
  • @to_date is used with < rather than <=, so a caller passing the first of the next month gets the whole previous month regardless of any time component. This is the same date-range care from the WHERE lesson.

Procedures that change data

Multiple statements in one transaction, with error handling
SQL
CREATE OR ALTER PROCEDURE usp_cancel_order
    @order_id    INT,
    @reason      NVARCHAR(200),
    @rows_changed INT OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    BEGIN TRY
        BEGIN TRANSACTION;

        UPDATE orders
        SET status = 'cancelled'
        WHERE order_id = @order_id
          AND status IN ('pending', 'processing');

        SET @rows_changed = @@ROWCOUNT;

        IF @rows_changed = 0
        BEGIN
            ROLLBACK TRANSACTION;
            THROW 50001, 'Order not found, or not in a cancellable state.', 1;
        END;

        INSERT INTO order_status_log (order_id, new_status, reason, changed_at)
        VALUES (@order_id, 'cancelled', @reason, SYSUTCDATETIME());

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF XACT_STATE() <> 0
            ROLLBACK TRANSACTION;
        THROW;
    END CATCH;
END;
  • Two statements that must both succeed: the status change and the log entry. The transaction makes them one unit, so a failure cannot leave a cancelled order with no audit record.
  • @@ROWCOUNT holds the number of rows the previous statement affected. Checking it is how the procedure tells "cancelled" from "there was nothing to cancel", which are different outcomes the caller needs to distinguish.
  • The status IN ('pending', 'processing') condition means a shipped order cannot be cancelled by this path. Encoding that rule here is exactly the kind of decision the trade-off section discusses.
  • THROW re-raises the original error so the caller sees the real cause rather than a generic failure. XACT_STATE() checks whether a transaction is still open before attempting to roll back.
  • An OUTPUT parameter returns a value to the caller alongside any result sets.

The honest trade-off

Stored procedures are frequently presented as a best practice. They are not automatically good, and the costs are real enough that many teams deliberately keep logic out of the database.

The core issue is that a procedure is a deployed object in a database, not a file in your repository. Everything your development process does for application code — version control, code review, automated tests, refactoring tools — has to be recreated for procedures, and usually ends up weaker.

The same business rule, in a procedure or in application code:

 Logic in a stored procedureLogic in application code
Version controlOnly if someone keeps the script in the repo and the deployed object never drifts from itInherent — the code in the repo is the code that runs
Code reviewPossible, and easy to bypass by changing the procedure on the serverEnforced by the pull request process
Automated testingNeeds a real database, test data and teardown; slow and awkward to isolateUnit tests run in milliseconds without a database
Refactoring supportRenaming a column will not update procedure bodies; you find out at run timeThe compiler finds every usage immediately
DebuggingLimited tooling, and stepping through T-SQL is awkwardFull debugger, breakpoints, inspection
Data-heavy workRuns next to the data — no round trips, no rows shipped over the networkEvery row fetched to the application and pushed back
Multiple applicationsOne definition of the rule, shared by every callerEach application needs its own copy, or a shared service in front
Who can change itAnyone with permission on the database, potentially without a deploymentRequires a build and deploy, which is a feature as much as a cost

Summary

  • A stored procedure is a named, parameterised block of SQL that can hold several statements and a transaction
  • Parameters are passed as values, never as SQL text, which is why they are safe against injection
  • The strongest case for a procedure is data-heavy work that would otherwise ship many rows over the network
  • The cost is process: version control, testing, review and refactoring are all weaker for database objects
  • Check @@ROWCOUNT and handle errors with TRY/CATCH, or a failure can leave a transaction open

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Write a procedure that adds a product to an existing order. It takes an order id, a product id and a quantity. It should refuse to add to an order that is not pending, and record the current product price as the unit price charged.

Show solution

The validation is the substance here. Checking the order exists and is pending before inserting prevents an order item attached to a shipped order, which no foreign key can catch on its own.

Reading the price from products at insert time captures what was charged. The order_items row then keeps that figure even after the product is repriced — the reason unit_price exists on order_items at all.

One transaction covers the insert and the stock decrement, so stock cannot be reduced for a line that failed to insert.

Worth noting the limits of this design: two people adding the same product to the same order concurrently will hit the UNIQUE constraint on (order_id, product_id), and one will get an error. Whether that should be an error or should increase the quantity instead is a business decision, not a technical one — and the procedure has to pick.

SQL
CREATE OR ALTER PROCEDURE usp_add_order_item
    @order_id   INT,
    @product_id INT,
    @quantity   INT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    IF @quantity <= 0
        THROW 50010, 'Quantity must be greater than zero.', 1;

    IF NOT EXISTS (
        SELECT 1 FROM orders WHERE order_id = @order_id AND status = 'pending'
    )
        THROW 50011, 'Order does not exist or is not pending.', 1;

    DECLARE @unit_price DECIMAL(10,2);

    SELECT @unit_price = unit_price
    FROM products
    WHERE product_id = @product_id AND discontinued = 0;

    IF @unit_price IS NULL
        THROW 50012, 'Product does not exist or is discontinued.', 1;

    BEGIN TRY
        BEGIN TRANSACTION;

        INSERT INTO order_items (order_id, product_id, quantity, unit_price)
        VALUES (@order_id, @product_id, @quantity, @unit_price);

        UPDATE products
        SET units_in_stock = units_in_stock - @quantity
        WHERE product_id = @product_id;

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

Think about it

Think about it

Your team is asked to move all pricing rules into stored procedures, on the grounds that "logic belongs close to the data". Pricing changes roughly twice a month and has 30 unit tests. What would you argue?

Show solution

The frequency of change and the existing test suite both point away from the database. Thirty unit tests that run in milliseconds would become thirty database tests needing a live schema, test data and cleanup — slower, more fragile, and harder to run on a laptop.

Twice-monthly changes also mean twice-monthly database deployments for logic that has nothing to do with data storage. That increases the number of things that can go wrong in a release.

"Close to the data" is a real argument when the work is data-heavy: bulk updates, large aggregations, anything where moving rows over the network dominates. Pricing a basket touches a handful of rows, so the round trip is not the cost.

A reasonable counter-proposal: keep pricing rules in application code with their tests, and use procedures for the operations that genuinely benefit — bulk imports, the monthly revaluation job. That is a position based on where the cost actually is, rather than on a principle applied uniformly.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Which is a genuine disadvantage of putting business logic in stored procedures?

Saved in this browser only.