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.
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
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 procedure | Logic in application code | |
|---|---|---|
| Version control | Only if someone keeps the script in the repo and the deployed object never drifts from it | Inherent — the code in the repo is the code that runs |
| Code review | Possible, and easy to bypass by changing the procedure on the server | Enforced by the pull request process |
| Automated testing | Needs a real database, test data and teardown; slow and awkward to isolate | Unit tests run in milliseconds without a database |
| Refactoring support | Renaming a column will not update procedure bodies; you find out at run time | The compiler finds every usage immediately |
| Debugging | Limited tooling, and stepping through T-SQL is awkward | Full debugger, breakpoints, inspection |
| Data-heavy work | Runs next to the data — no round trips, no rows shipped over the network | Every row fetched to the application and pushed back |
| Multiple applications | One definition of the rule, shared by every caller | Each application needs its own copy, or a shared service in front |
| Who can change it | Anyone with permission on the database, potentially without a deployment | Requires 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.
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.
Saved in this browser only.