Day 17: Stored procedures
On this page
By the end of this lesson you can
- Create, alter, execute and drop stored procedures with
CREATE OR ALTER PROCEDURE. - Declare input parameters with defaults, and call procedures with named parameters.
- Use
IF … ELSE,WHILE,BEGIN … ENDandRETURNto control execution. - Return information three ways — result sets,
OUTPUTparameters and an integer return code — and choose the right one. - Write a data-changing procedure that validates input, uses
XACT_ABORTand a transaction, and reports what it did. - Build a flexible search procedure with
sp_executesql, passing values as parameters and never concatenating them. - Explain how
EXECUTEpermission and ownership chaining let users run a procedure without direct table access.
A. Lesson overview
| Week | 3 — Data modification, programmability and error handling |
| Level | Intermediate → Advanced |
| Estimated time | 2 h 45 min essential + 30 min optional stretch |
| Prerequisites | Days 15–16 (safe modification, transactions, XACT_ABORT); Day 11’s dynamic SQL primer |
| Syllabus topic | Stored Procedures — parameters, return values, output parameters, dynamic SQL |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — What a procedure is and why to use one | 15 min |
| Part 2 — Creating and executing; input parameters and defaults | 20 min |
Part 3 — Control of flow: IF, WHILE, BEGIN…END, RETURN |
15 min |
| Part 4 — Output parameters, return codes and result sets | 20 min |
| Part 5 — Procedures that change data | 15 min |
| Part 6 — Dynamic SQL inside procedures; security | 20 min |
| Worked examples | 20 min |
| Guided lab | 20 min |
| Independent practice | 20 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. The customer-service application team wants a small set of well-defined database “APIs”: look up a customer’s summary, add a customer, transfer stock between warehouses, and search orders with any combination of filters. Each should validate its inputs, behave predictably on errors and be safe to expose to users who should not have direct table access.
B. Theory explained clearly
Part 1 — What a stored procedure is
A stored procedure is a named, parameterised batch of T-SQL stored in the database. It can contain almost any statements: queries, data changes, transactions, control of flow, calls to other procedures.
| Benefit | Why it matters |
|---|---|
| Encapsulation | Callers use a stable interface (EXEC Lab.usp_AddCustomer …) while the implementation can change |
| Security | Users can be granted EXECUTE on the procedure without any rights on the underlying tables |
| Consistency | Business rules (validation, transaction handling) live in one place |
| Network efficiency | One call runs many statements |
| Plan reuse | SQL Server compiles a plan on first execution and caches it for reuse (with a caveat — parameter sniffing — on Day 24) |
Functions (Day 18) return values or tables and can be used inside queries; procedures cannot be used inside a SELECT, but can change data, manage transactions and return several result sets.
Part 2 — Creating and executing
CREATE OR ALTER PROCEDURE Lab.usp_GetCustomerOrders
@CustomerID int,
@FromDate date = '2024-01-01', -- default: optional parameter
@ToDate date = '2026-01-01'
AS
BEGIN
SET NOCOUNT ON; -- suppress "(n rows affected)" messages
SELECT OrderID, OrderDate, OrderStatus, SalesChannel
FROM Sales.SalesOrder
WHERE CustomerID = @CustomerID
AND OrderDate >= @FromDate
AND OrderDate < @ToDate
ORDER BY OrderDate, OrderID;
END;
GO
| Element | Notes |
|---|---|
CREATE OR ALTER |
Creates or replaces, keeping permissions granted on the procedure |
| Schema-qualified name | Lab.usp_…. Avoid the sp_ prefix: SQL Server first looks for sp_ procedures in master |
| Parameters | @Name type [= default] [OUTPUT]. Up to 2,100 |
AS BEGIN … END |
The body; BEGIN…END is optional but clarifies where it ends |
SET NOCOUNT ON |
Reduces network chatter and avoids confusing some client libraries |
GO |
CREATE PROCEDURE must be the only statement in its batch, and the body extends to the end of the batch |
Executing:
EXEC Lab.usp_GetCustomerOrders @CustomerID = 1, @FromDate = '2025-12-01'; -- named: clear, order-independent
EXEC Lab.usp_GetCustomerOrders 1, '2025-12-01', '2026-01-01'; -- positional: fragile
EXEC Lab.usp_GetCustomerOrders @CustomerID = 1, @ToDate = DEFAULT; -- explicitly use a default
Prefer named parameters: adding a parameter later then can’t silently shift positional arguments. Parameters can’t take expressions directly (@FromDate = DATEADD(…) is not allowed in EXEC); compute into a variable first.
Inspect a procedure’s definition with EXEC sp_helptext 'Lab.usp_GetCustomerOrders'; or SELECT OBJECT_DEFINITION(OBJECT_ID('Lab.usp_GetCustomerOrders'));, or in Object Explorer under Programmability → Stored Procedures.
Part 3 — Control of flow
| Statement | Purpose |
|---|---|
IF condition statement [ELSE statement] |
Conditional execution. Wrap several statements in BEGIN … END |
WHILE condition BEGIN … END |
Loop; BREAK exits, CONTINUE skips to the next iteration |
RETURN [integer] |
Leave the procedure immediately, optionally with a status code |
IF EXISTS (SELECT …) |
Test whether any row qualifies |
THROW |
Raise an error (Day 20) |
Prefer set-based statements over WHILE loops for row processing; loops are for batching (Day 15’s stretch), retries and genuinely procedural logic.
Part 4 — Returning information
| Mechanism | Carries | Typical use |
|---|---|---|
Result sets (SELECT without assignment) |
Rows and columns — any number of sets | Data for display |
OUTPUT parameters |
Scalar values back to the caller | New IDs, counts, messages |
Return code (RETURN n) |
One integer | Status: 0 = success, non-zero = a defined problem. Not for data |
CREATE OR ALTER PROCEDURE Lab.usp_GetCustomerStats
@CustomerID int,
@Orders int OUTPUT,
@LifetimeNet decimal(12,2) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM Sales.Customer WHERE CustomerID = @CustomerID)
RETURN 1; -- 1 = customer not found
SELECT @Orders = COUNT(*) …;
RETURN 0;
END;
GO
DECLARE @o int, @n decimal(12,2), @rc int;
EXEC @rc = Lab.usp_GetCustomerStats @CustomerID = 12, @Orders = @o OUTPUT, @LifetimeNet = @n OUTPUT;
SELECT @rc AS ReturnCode, @o AS Orders, @n AS LifetimeNet;
The keyword OUTPUT is required both in the declaration and in the call; forgetting it in the call is not an error — the variable simply stays unchanged. A procedure that ends without RETURN returns 0. Error handling should use errors (THROW), not only return codes; codes are for expected outcomes (“not found”, “refused”).
INSERT INTO #t EXEC proc … captures a procedure’s result set into a table — the column list must match, the procedure must return a single compatible shape, and INSERT … EXEC cannot be nested.
Part 5 — Procedures that change data
A dependable pattern (completed with TRY…CATCH on Day 20):
CREATE OR ALTER PROCEDURE Lab.usp_DoSomething
@…
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON; -- any error rolls back and stops
-- 1. Validate inputs (cheap checks first); RETURN a code for expected refusals
-- 2. BEGIN TRANSACTION
-- 3. Do the work with set-based statements, locking what you read if you write it back
-- 4. COMMIT TRANSACTION
-- 5. Report: OUTPUT parameters / result set; RETURN 0
END;
Settings such as SET NOCOUNT, SET XACT_ABORT and SET TRANSACTION ISOLATION LEVEL made inside a procedure revert when it ends, so they don’t leak into the caller.
Part 6 — Dynamic SQL inside procedures, and security
The “catch-all” search problem
A search screen has five optional filters. A static query covering every combination looks like:
WHERE (@CustomerID IS NULL OR o.CustomerID = @CustomerID)
AND (@Status IS NULL OR o.OrderStatus = @Status)
…
It works, but SQL Server compiles one plan for all combinations, which is often poor (Day 24). Two common remedies:
- Add
OPTION (RECOMPILE)so each execution gets a plan for its actual values. - Build the
WHEREclause dynamically with only the supplied filters, and run it withsp_executesql, passing every value as a parameter:
DECLARE @sql nvarchar(max) = N'SELECT … FROM Sales.SalesOrder AS o WHERE 1 = 1';
IF @CustomerID IS NOT NULL SET @sql += N' AND o.CustomerID = @CustomerID';
IF @Status IS NOT NULL SET @sql += N' AND o.OrderStatus = @Status';
SET @sql += N' ORDER BY o.OrderDate, o.OrderID;';
EXEC sys.sp_executesql @sql,
N'@CustomerID int, @Status varchar(10)', -- parameter definitions
@CustomerID = @CustomerID, @Status = @Status; -- values
The text contains @CustomerID, not the customer’s number. Each distinct text gets its own cached plan, reused across calls with different values. Never write … + CAST(@CustomerID AS nvarchar(10)) or … + @Status — concatenating values is how SQL injection happens (Day 28).
Permissions and ownership chaining
GRANT EXECUTE ON Lab.usp_GetCustomerOrders TO SomeRole;
When a procedure and the tables it uses have the same owner (here dbo owns every schema), SQL Server does not check the caller’s permissions on those tables — ownership chaining. The caller needs only EXECUTE. Dynamic SQL breaks the chain: statements run by sp_executesql are checked against the caller’s own permissions, unless the procedure is signed with a certificate or uses EXECUTE AS. Keep that in mind when you move a static procedure to dynamic SQL.
C. Dataset and starting state
| Dataset | Shared synthetic dataset; data changes go to Lab copies |
| Database | RetailLab |
| Read from | Sales.Customer, Sales.SalesOrder, Sales.SalesOrderLine, Inventory.Product, Inventory.Stock |
| Changed | Lab.Customer, Lab.Stock |
| Permissions | CREATE PROCEDURE in the database and ALTER on schema Lab |
| Objects created | Lab.usp_GetCustomerOrders, Lab.usp_GetCustomerStats, Lab.usp_CustomerSummary, Lab.usp_AddCustomer, Lab.usp_TransferStock, Lab.usp_SearchOrders, Lab.usp_ProductSalesByYear, Lab.usp_MonthlySales — keep Lab.usp_TransferStock and Lab.usp_AddCustomer; Day 20 extends them |
Setup (re-runnable; resets only the two Lab copies):
USE RetailLab;
GO
DROP TABLE IF EXISTS Lab.Customer, Lab.Stock;
SELECT * INTO Lab.Customer FROM Sales.Customer;
ALTER TABLE Lab.Customer ADD CONSTRAINT PK_LabCustomer PRIMARY KEY (CustomerID);
CREATE UNIQUE INDEX UX_LabCustomer_Email ON Lab.Customer (Email) WHERE Email IS NOT NULL;
SELECT WarehouseID, ProductID, QuantityOnHand, ReorderLevel INTO Lab.Stock FROM Inventory.Stock;
ALTER TABLE Lab.Stock ADD CONSTRAINT PK_LabStock PRIMARY KEY (WarehouseID, ProductID);
ALTER TABLE Lab.Stock ADD CONSTRAINT CK_LabStock_NonNegative CHECK (QuantityOnHand >= 0);
GO
Validation. Expected: 40, 1142, 0.
SELECT (SELECT COUNT(*) FROM Lab.Customer) AS LabCustomers,
(SELECT SUM(QuantityOnHand) FROM Lab.Stock) AS TotalUnits,
(SELECT COUNT(*) FROM sys.procedures WHERE schema_id = SCHEMA_ID('Lab')) AS LabProcedures;
(LabProcedures may be higher if you have done this lesson before; that is fine — CREATE OR ALTER replaces them.)
D. Worked examples
Worked example 1 — A simple procedure with defaults
CREATE OR ALTER PROCEDURE Lab.usp_GetCustomerOrders
@CustomerID int,
@FromDate date = '2024-01-01',
@ToDate date = '2026-01-01'
AS
BEGIN
SET NOCOUNT ON;
SELECT OrderID, OrderDate, OrderStatus, SalesChannel
FROM Sales.SalesOrder
WHERE CustomerID = @CustomerID
AND OrderDate >= @FromDate
AND OrderDate < @ToDate
ORDER BY OrderDate, OrderID;
END;
GO
EXEC Lab.usp_GetCustomerOrders @CustomerID = 1, @FromDate = '2025-12-01';
EXEC Lab.usp_GetCustomerOrders @CustomerID = 1; -- all dates via defaults
Expected result: first call: 3 rows — 1350 (2025-12-03, Delivered), 1372 (Pending), 1373 (Pending). Second call: all 27 of customer 1’s orders. Calling without @CustomerID fails: Procedure or function ‘usp_GetCustomerOrders’ expects parameter ‘@CustomerID’, which was not supplied.
Worked example 2 — OUTPUT parameters and a return code
CREATE OR ALTER PROCEDURE Lab.usp_GetCustomerStats
@CustomerID int,
@Orders int OUTPUT,
@LifetimeNet decimal(12,2) OUTPUT,
@LastOrder date OUTPUT
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM Sales.Customer WHERE CustomerID = @CustomerID)
RETURN 1; -- not found
SELECT @Orders = COUNT(DISTINCT o.OrderID),
@LifetimeNet = COALESCE(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)), 0),
@LastOrder = MAX(o.OrderDate)
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = @CustomerID
AND o.OrderStatus <> 'Cancelled';
RETURN 0;
END;
GO
DECLARE @rc int, @orders int, @net decimal(12,2), @last date;
EXEC @rc = Lab.usp_GetCustomerStats @CustomerID = 12,
@Orders = @orders OUTPUT, @LifetimeNet = @net OUTPUT, @LastOrder = @last OUTPUT;
SELECT @rc AS ReturnCode, @orders AS Orders, @net AS LifetimeNet, @last AS LastOrder;
EXEC @rc = Lab.usp_GetCustomerStats @CustomerID = 99,
@Orders = @orders OUTPUT, @LifetimeNet = @net OUTPUT, @LastOrder = @last OUTPUT;
SELECT @rc AS ReturnCode;
Expected result: customer 12 → 0, 27, 8810.01, 2025-12-23. Customer 99 → 1. Customer 37 (exists, no orders) → 0, 0, 0.00, NULL.
Try the trap: remove OUTPUT from @Orders = @orders OUTPUT in the call. No error — @orders keeps its previous value (27), which could be silently wrong.
Worked example 3 — Several result sets
CREATE OR ALTER PROCEDURE Lab.usp_CustomerSummary
@CustomerID int
AS
BEGIN
SET NOCOUNT ON;
-- 1. Who
SELECT CustomerID, FirstName, LastName, Email, City, Country, CustomerSegment
FROM Sales.Customer
WHERE CustomerID = @CustomerID;
-- 2. Latest three orders
SELECT TOP (3) OrderID, OrderDate, OrderStatus
FROM Sales.SalesOrder
WHERE CustomerID = @CustomerID
ORDER BY OrderDate DESC, OrderID DESC;
-- 3. Net sales by year
SELECT YEAR(o.OrderDate) AS OrderYear,
COUNT(DISTINCT o.OrderID) AS Orders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = @CustomerID AND o.OrderStatus <> 'Cancelled'
GROUP BY YEAR(o.OrderDate)
ORDER BY OrderYear;
END;
GO
EXEC Lab.usp_CustomerSummary @CustomerID = 1;
Expected result: three grids — Alice Morgan’s details; orders 1373, 1372, 1350; and 2024: 13 orders, 4387.48 / 2025: 14 orders, 4914.22.
Worked example 4 — A data-changing procedure with an OUTPUT key
CREATE OR ALTER PROCEDURE Lab.usp_AddCustomer
@FirstName nvarchar(50),
@LastName nvarchar(50),
@Email nvarchar(255) = NULL,
@City nvarchar(60),
@Country nvarchar(60),
@NewCustomerID int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
-- Validation: expected refusals return codes
IF NULLIF(TRIM(@FirstName), N'') IS NULL OR NULLIF(TRIM(@LastName), N'') IS NULL
RETURN 1; -- missing name
IF @Email IS NOT NULL AND EXISTS (SELECT 1 FROM Lab.Customer WHERE Email = TRIM(@Email))
RETURN 2; -- duplicate email
BEGIN TRANSACTION;
SELECT @NewCustomerID = COALESCE(MAX(CustomerID), 0) + 1
FROM Lab.Customer WITH (UPDLOCK, HOLDLOCK); -- serialise ID allocation
INSERT INTO Lab.Customer (CustomerID, FirstName, LastName, Email, Phone, City, Country,
CustomerSegment, DateJoined, IsActive)
VALUES (@NewCustomerID, TRIM(@FirstName), TRIM(@LastName), LOWER(TRIM(@Email)), NULL,
TRIM(@City), TRIM(@Country), 'Consumer', '2026-01-02', 1);
COMMIT TRANSACTION;
RETURN 0;
END;
GO
DECLARE @rc int, @id int;
EXEC @rc = Lab.usp_AddCustomer @FirstName = N'Kofi', @LastName = N'Mensah',
@Email = N' Kofi.Mensah@Example.com', @City = N'London', @Country = N'United Kingdom',
@NewCustomerID = @id OUTPUT;
SELECT @rc AS ReturnCode, @id AS NewCustomerID;
EXEC @rc = Lab.usp_AddCustomer @FirstName = N'Kofi', @LastName = N'Mensah',
@Email = N'kofi.mensah@example.com', @City = N'London', @Country = N'United Kingdom',
@NewCustomerID = @id OUTPUT;
SELECT @rc AS ReturnCode;
Expected result: first call 0, 41 (email stored as kofi.mensah@example.com); second call 2 (duplicate). UPDLOCK, HOLDLOCK on the MAX read stops two simultaneous calls from taking the same ID (Day 16). Day 26 replaces this with a SEQUENCE.
Worked example 5 — A dynamic search procedure
CREATE OR ALTER PROCEDURE Lab.usp_SearchOrders
@CustomerID int = NULL,
@FromDate date = NULL,
@ToDate date = NULL, -- exclusive
@Status varchar(10) = NULL,
@MinNet decimal(10,2) = NULL,
@Debug bit = 0
AS
BEGIN
SET NOCOUNT ON;
DECLARE @sql nvarchar(max) = N'
SELECT o.OrderID, o.CustomerID, o.OrderDate, o.OrderStatus,
CAST(t.OrderNet AS decimal(10,2)) AS OrderNet
FROM Sales.SalesOrder AS o
CROSS APPLY (SELECT SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrderLine AS l WHERE l.OrderID = o.OrderID) AS t
WHERE 1 = 1';
IF @CustomerID IS NOT NULL SET @sql += N' AND o.CustomerID = @CustomerID';
IF @FromDate IS NOT NULL SET @sql += N' AND o.OrderDate >= @FromDate';
IF @ToDate IS NOT NULL SET @sql += N' AND o.OrderDate < @ToDate';
IF @Status IS NOT NULL SET @sql += N' AND o.OrderStatus = @Status';
IF @MinNet IS NOT NULL SET @sql += N' AND t.OrderNet >= @MinNet';
SET @sql += N'
ORDER BY o.OrderDate, o.OrderID;';
IF @Debug = 1 PRINT @sql;
EXEC sys.sp_executesql @sql,
N'@CustomerID int, @FromDate date, @ToDate date, @Status varchar(10), @MinNet decimal(10,2)',
@CustomerID = @CustomerID, @FromDate = @FromDate, @ToDate = @ToDate,
@Status = @Status, @MinNet = @MinNet;
END;
GO
EXEC Lab.usp_SearchOrders @Status = 'Pending';
EXEC Lab.usp_SearchOrders @CustomerID = 1, @FromDate = '2025-12-01', @Debug = 1;
EXEC Lab.usp_SearchOrders @MinNet = 1500;
Expected result: 4 pending orders (1372–1375); customer 1’s 3 December orders (and the generated SQL in Messages, containing AND o.CustomerID = @CustomerID AND o.OrderDate >= @FromDate); 3 orders worth at least 1,500: 1014 (1577.70), 1227 (1523.00), 1312 (1574.00).
What to notice: every parameter is declared in the definition string and passed, even when unused — that is allowed and keeps the call simple. Try @Status = 'Pending'' OR 1=1 --': it returns no rows, because the whole string is compared as a value. It is never executed as code.
E. Guided practical lab — A stock-transfer procedure
Turn Day 16’s transfer script into Lab.usp_TransferStock with input validation, return codes, an OUTPUT message and the same locking strategy.
Return codes (the procedure’s contract)
| Code | Meaning |
|---|---|
| 0 | Transferred |
| 1 | Invalid input (quantity ≤ 0, or same warehouse) |
| 2 | Source or destination has no stock record for the product |
| 3 | Not enough stock |
Step 1 — Create the procedure
- Do: Create it in one batch.
- Run:
USE RetailLab; GO CREATE OR ALTER PROCEDURE Lab.usp_TransferStock @ProductID int, @FromWarehouse int, @ToWarehouse int, @Qty int, @Message nvarchar(200) OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; IF @Qty IS NULL OR @Qty <= 0 OR @FromWarehouse = @ToWarehouse BEGIN SET @Message = N'Invalid input.'; RETURN 1; END; DECLARE @Available int, @Rows int; BEGIN TRANSACTION; SELECT @Available = MAX(CASE WHEN WarehouseID = @FromWarehouse THEN QuantityOnHand END), @Rows = COUNT(*) FROM Lab.Stock WITH (UPDLOCK, HOLDLOCK) WHERE ProductID = @ProductID AND WarehouseID IN (@FromWarehouse, @ToWarehouse); IF @Rows < 2 BEGIN ROLLBACK TRANSACTION; SET @Message = N'No stock record in one of the warehouses.'; RETURN 2; END; IF @Available < @Qty BEGIN ROLLBACK TRANSACTION; SET @Message = CONCAT(N'Only ', @Available, N' available.'); RETURN 3; END; UPDATE Lab.Stock SET QuantityOnHand = QuantityOnHand + CASE WHEN WarehouseID = @FromWarehouse THEN -@Qty ELSE @Qty END WHERE ProductID = @ProductID AND WarehouseID IN (@FromWarehouse, @ToWarehouse); COMMIT TRANSACTION; SET @Message = CONCAT(N'Transferred ', @Qty, N' unit(s) of product ', @ProductID, N'.'); RETURN 0; END; GO - Observe: Commands completed successfully.
- Why it matters: Every exit path either commits or rolls back, and sets
@Message. - Confirm:
SELECT OBJECT_ID('Lab.usp_TransferStock');is not NULL.
Step 2 — A test harness
-
Do: Call it with several cases and collect results in a table variable.
-
Run:
DECLARE @Results TABLE (TestCase nvarchar(40), ReturnCode int, Msg nvarchar(200)); DECLARE @rc int, @msg nvarchar(200); EXEC @rc = Lab.usp_TransferStock 26, 2, 1, 10, @msg OUTPUT; INSERT @Results VALUES (N'Valid transfer', @rc, @msg); EXEC @rc = Lab.usp_TransferStock 26, 2, 2, 10, @msg OUTPUT; INSERT @Results VALUES (N'Same warehouse', @rc, @msg); EXEC @rc = Lab.usp_TransferStock 26, 2, 1, 0, @msg OUTPUT; INSERT @Results VALUES (N'Zero quantity', @rc, @msg); EXEC @rc = Lab.usp_TransferStock 3, 1, 2, 1, @msg OUTPUT; INSERT @Results VALUES (N'No destination record', @rc, @msg); EXEC @rc = Lab.usp_TransferStock 2, 2, 1, 5, @msg OUTPUT; INSERT @Results VALUES (N'Not enough stock', @rc, @msg); SELECT * FROM @Results; SELECT SUM(QuantityOnHand) AS TotalUnits, @@TRANCOUNT AS OpenTran FROM Lab.Stock; -
Observe:
TestCase ReturnCode Msg Valid transfer 0 Transferred 10 unit(s) of product 26. Same warehouse 1 Invalid input. Zero quantity 1 Invalid input. No destination record 2 No stock record in one of the warehouses. Not enough stock 3 Only 0 available. TotalUnits1142,OpenTran0. -
Why it matters: A small harness documents the contract and catches regressions when the procedure changes. Positional arguments are used here for compactness; in application code, prefer named ones.
-
Confirm: the five rows and the invariant.
Step 3 — Grant execute, not table access (demonstration)
- Do: Create a test user without a login, grant only
EXECUTE, and impersonate it. - Run:
CREATE USER StockClerk WITHOUT LOGIN; GRANT EXECUTE ON Lab.usp_TransferStock TO StockClerk; EXECUTE AS USER = 'StockClerk'; DECLARE @rc int, @msg nvarchar(200); EXEC @rc = Lab.usp_TransferStock 26, 1, 2, 1, @msg OUTPUT; SELECT @rc AS ReturnCode, @msg AS Msg; SELECT TOP (1) * FROM Lab.Stock; -- direct access REVERT; DROP USER StockClerk; - Observe: the procedure succeeds (
0, Transferred 1 unit(s)…); the directSELECTfails: The SELECT permission was denied on the object ‘Stock’, database ‘RetailLab’, schema ‘Lab’. - Why it matters: Ownership chaining: the clerk can do exactly what the procedure allows and nothing else.
- Confirm: after
REVERT,SELECT USER_NAME();returnsdbo.
F. Independent practice
Task 1 — Product sales for a year
Create Lab.usp_ProductSalesByYear @Year int, @Top int = 10 returning the top @Top products by net sales in that year (non-cancelled): ProductCode, ProductName, Units, NetSales. Execute it for 2025 with @Top = 3.
Success criteria: TNT-002 (26 units, 8777.35), TNT-003 (22, 5241.45), SLP-002 (17, 4903.60).
Task 2 — Stats with validation
Extend Lab.usp_GetCustomerStats so that a NULL @CustomerID returns code 9 and sets all outputs to NULL, and so that a customer with no orders returns code 4 (while still setting @Orders = 0). Test customers NULL, 12, 37 and 99.
Success criteria: NULL → 9; 12 → 0 (27, 8810.01, 2025-12-23); 37 → 4 (0, 0.00, NULL); 99 → 1.
Task 3 — Search with a sort option (safely)
Add a parameter @SortBy varchar(10) = 'Date' to Lab.usp_SearchOrders accepting Date, Net or Customer. Sorting can’t be parameterised as a value, so build the ORDER BY from a whitelist.
Success criteria: @MinNet = 1500, @SortBy = 'Net' returns 1014, 1312, 1227 (descending net); an unknown @SortBy such as 'x; DROP TABLE' falls back to date order (or returns code 1 — your choice, documented).
Task 4 — Monthly sales with INSERT … EXEC (challenging)
Create Lab.usp_MonthlySales @Year int returning MonthNo, Orders, NetSales for each month with sales. In a separate batch, capture its output for 2025 with INSERT INTO #Monthly EXEC …, then compute a year-to-date column in the caller.
Success criteria: 12 rows; January 3749.57 (YTD 3749.57); December 9718.65 (YTD 63741.94).
Stretch challenge — A procedure that calls a procedure
Write Lab.usp_RebalanceProduct @ProductID int, @Message nvarchar(400) OUTPUT that moves stock so both warehouses hold as equal a quantity as possible (the source keeps the extra unit if the total is odd), by calling Lab.usp_TransferStock. Return its code. Test with products 26 and 2.
Success criteria: reset Lab.Stock first. Product 26 (18 / 40) → moves 11 from warehouse 2: result 29 / 29. Product 2 (25 / 0) → moves 12 from warehouse 1: result 13 / 12. Total stays 1142.
Hint
Read both quantities; @Move = (larger - smaller) / 2 (integer division); call EXEC @rc = Lab.usp_TransferStock @ProductID, @from, @to, @Move, @msg OUTPUT;. If @Move = 0, return 0 without calling. Because usp_TransferStock manages its own transaction, don’t open one in the caller (Day 20 shows how to make procedures safe when they are called inside a transaction).
G. Solutions and explanations
Show solution 1
CREATE OR ALTER PROCEDURE Lab.usp_ProductSalesByYear
@Year int,
@Top int = 10
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (@Top)
p.ProductCode, p.ProductName,
SUM(l.Quantity) AS Units,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= DATEFROMPARTS(@Year, 1, 1)
AND o.OrderDate < DATEFROMPARTS(@Year + 1, 1, 1)
GROUP BY p.ProductCode, p.ProductName
ORDER BY NetSales DESC, p.ProductCode;
END;
GO
EXEC Lab.usp_ProductSalesByYear @Year = 2025, @Top = 3;
Reasoning. TOP (@Top) needs parentheses for a variable. The date range is half-open on the column, keeping it index-friendly.
Show solution 2
CREATE OR ALTER PROCEDURE Lab.usp_GetCustomerStats
@CustomerID int,
@Orders int OUTPUT,
@LifetimeNet decimal(12,2) OUTPUT,
@LastOrder date OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SELECT @Orders = NULL, @LifetimeNet = NULL, @LastOrder = NULL;
IF @CustomerID IS NULL RETURN 9;
IF NOT EXISTS (SELECT 1 FROM Sales.Customer WHERE CustomerID = @CustomerID) RETURN 1;
SELECT @Orders = COUNT(DISTINCT o.OrderID),
@LifetimeNet = COALESCE(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)), 0),
@LastOrder = MAX(o.OrderDate)
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = @CustomerID AND o.OrderStatus <> 'Cancelled';
IF @Orders = 0 RETURN 4;
RETURN 0;
END;
GO
Reasoning. Resetting the outputs first means a refused call never leaves stale values in the caller’s variables. An aggregate query without GROUP BY always returns one row, so @Orders becomes 0 (not NULL) for a customer with no orders.
Show solution 3
-- Inside Lab.usp_SearchOrders, add the parameter @SortBy varchar(10) = 'Date'
-- and replace the ORDER BY line with:
SET @sql += CASE @SortBy
WHEN 'Net' THEN N' ORDER BY t.OrderNet DESC, o.OrderID;'
WHEN 'Customer' THEN N' ORDER BY o.CustomerID, o.OrderDate, o.OrderID;'
ELSE N' ORDER BY o.OrderDate, o.OrderID;'
END;
Reasoning. ORDER BY columns are identifiers, not values, so they cannot be passed as sp_executesql parameters. A CASE that maps known inputs to fixed text is a whitelist: whatever the caller sends, only one of three pre-written fragments can reach the SQL. Never concatenate @SortBy itself.
Show solution 4
CREATE OR ALTER PROCEDURE Lab.usp_MonthlySales
@Year int
AS
BEGIN
SET NOCOUNT ON;
SELECT MONTH(o.OrderDate) AS MonthNo,
COUNT(DISTINCT o.OrderID) AS Orders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= DATEFROMPARTS(@Year, 1, 1) AND o.OrderDate < DATEFROMPARTS(@Year + 1, 1, 1)
GROUP BY MONTH(o.OrderDate)
ORDER BY MonthNo;
END;
GO
CREATE TABLE #Monthly (MonthNo int, Orders int, NetSales decimal(12,2));
INSERT INTO #Monthly (MonthNo, Orders, NetSales)
EXEC Lab.usp_MonthlySales @Year = 2025;
SELECT MonthNo, Orders, NetSales,
SUM(NetSales) OVER (ORDER BY MonthNo ROWS UNBOUNDED PRECEDING) AS YTD
FROM #Monthly
ORDER BY MonthNo;
DROP TABLE #Monthly;
Reasoning. INSERT … EXEC is the standard way to reuse a procedure’s result set. Its limits: one compatible result shape, no nesting, and the caller depends on the procedure’s column order. An inline table-valued function (Day 18) is usually a better building block when results are meant to be reused in queries.
Show the stretch solution
CREATE OR ALTER PROCEDURE Lab.usp_RebalanceProduct
@ProductID int,
@Message nvarchar(400) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @q1 int, @q2 int, @move int, @from int, @to int, @rc int, @msg nvarchar(200);
SELECT @q1 = MAX(CASE WHEN WarehouseID = 1 THEN QuantityOnHand END),
@q2 = MAX(CASE WHEN WarehouseID = 2 THEN QuantityOnHand END)
FROM Lab.Stock
WHERE ProductID = @ProductID;
IF @q1 IS NULL OR @q2 IS NULL
BEGIN
SET @Message = N'Product is not stocked in both warehouses.';
RETURN 2;
END;
SELECT @from = CASE WHEN @q1 >= @q2 THEN 1 ELSE 2 END,
@to = CASE WHEN @q1 >= @q2 THEN 2 ELSE 1 END,
@move = ABS(@q1 - @q2) / 2;
IF @move = 0
BEGIN
SET @Message = N'Already balanced.';
RETURN 0;
END;
EXEC @rc = Lab.usp_TransferStock @ProductID = @ProductID, @FromWarehouse = @from,
@ToWarehouse = @to, @Qty = @move, @Message = @msg OUTPUT;
SET @Message = CONCAT(N'Rebalance: ', @msg);
RETURN @rc;
END;
GO
DECLARE @rc int, @m nvarchar(400);
EXEC @rc = Lab.usp_RebalanceProduct @ProductID = 26, @Message = @m OUTPUT; SELECT @rc, @m;
EXEC @rc = Lab.usp_RebalanceProduct @ProductID = 2, @Message = @m OUTPUT; SELECT @rc, @m;
SELECT ProductID, WarehouseID, QuantityOnHand FROM Lab.Stock WHERE ProductID IN (2, 26) ORDER BY ProductID, WarehouseID;
Reasoning. The quantities are read without locks, so in a busy system they could change before the transfer — but the transfer re-checks availability under UPDLOCK, so the worst case is a refused or slightly imperfect rebalance, never negative stock. Integer division (22 / 2 = 11, 25 / 2 = 12) gives the move size.
H. Knowledge check
-
Understanding. Name three ways a procedure can return information, and say what each is best for.
-
Debugging. A caller writes
EXEC Lab.usp_GetCustomerStats @CustomerID = 12, @Orders = @o, @LifetimeNet = @n, @LastOrder = @d;and finds@ois NULL. Why? -
Query interpretation. A search procedure builds
N'… WHERE o.OrderStatus = ''' + @Status + N''''and runs it withEXEC(@sql). What is wrong, and how do you fix it? -
Understanding. A user has
EXECUTEonLab.usp_TransferStockbut no permissions onLab.Stock. Why does the procedure still work — and when would it stop working? -
Debugging.
CREATE OR ALTER PROCEDURE Lab.usp_X AS SELECT 1; EXEC Lab.usp_X;(on one line, noGO) creates a procedure that calls itself. Why?
Show the answers
-
Result sets (rows for display);
OUTPUTparameters (scalar values such as new IDs or messages); the return code (an integer status for expected outcomes — not for data). -
OUTPUTis missing in the call, so the values are not copied back. It must be specified in both the declaration and the call. -
It concatenates a value into the SQL text, allowing SQL injection (and breaking on apostrophes). Use
sp_executesqlwith@Statusas a declared parameter:… WHERE o.OrderStatus = @Status. -
Ownership chaining: the procedure and the table share an owner, so SQL Server checks only the
EXECUTEpermission. It would fail if the procedure used dynamic SQL againstLab.Stock(the chain is broken) or if the objects had different owners. -
The procedure body extends to the end of the batch, so
EXEC Lab.usp_X;became part of the procedure. PutGOafter theCREATEstatement.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| ‘CREATE/ALTER PROCEDURE’ must be the first statement in a query batch. | Missing GO before CREATE |
Add GO |
| Procedure contains statements you didn’t intend | No GO after CREATE |
Add GO after END; and re-create |
| Procedure or function ‘…’ expects parameter ‘@…’, which was not supplied. | Required parameter missing | Supply it or give it a default |
| … has too many arguments specified. | Extra arguments | Check the parameter list |
| ‘@…’ is not a parameter for procedure ‘…’. | Misspelt named parameter | Check the parameter name |
OUTPUT variable unchanged |
OUTPUT missing in the call |
Add it |
| Transaction count after EXECUTE indicates a mismatching number of BEGIN and COMMIT statements. (266) | A procedure exited with a different @@TRANCOUNT |
Every path must commit or roll back; see Day 20 |
| The SELECT permission was denied… inside a procedure | Dynamic SQL broke the ownership chain | Grant the needed permission, or sign the procedure |
| An INSERT EXEC statement cannot be nested. | Procedure called via INSERT … EXEC itself uses INSERT … EXEC |
Use a table-valued function or temp table handover |
Cleanup. Keep Lab.usp_TransferStock, Lab.usp_AddCustomer and the Lab.Stock/Lab.Customer copies — Day 20 extends them (its setup recreates them anyway). Remove the rest if you like:
DROP PROCEDURE IF EXISTS Lab.usp_GetCustomerOrders, Lab.usp_GetCustomerStats, Lab.usp_CustomerSummary,
Lab.usp_SearchOrders, Lab.usp_ProductSalesByYear, Lab.usp_MonthlySales,
Lab.usp_RebalanceProduct;
IF USER_ID('StockClerk') IS NOT NULL DROP USER StockClerk;
Core data is unchanged.
J. Completion checklist, recap and references
Completion checklist
- Worked example 2 returned
0, 27, 8810.01, 2025-12-23for customer 12 and1for customer 99. - Worked example 5’s injection attempt returned no rows.
- Your transfer harness produced codes 0, 1, 1, 2, 3 with the total still 1142.
- The
StockClerkuser could run the procedure but not read the table. - Tasks 1–4 match the success criteria.
Recap
CREATE OR ALTER PROCEDURE schema.namein its own batch;SET NOCOUNT ON; call with named parameters.- Defaults make parameters optional;
OUTPUTmust appear in both declaration and call;RETURN ngives a status code (0 = success). - Data-changing procedures: validate,
SET XACT_ABORT ON, transaction, set-based work, commit, report. - Dynamic SQL in procedures: build only the needed clauses, pass values via
sp_executesqlparameters, whitelist identifiers. GRANT EXECUTEplus ownership chaining gives controlled access; dynamic SQL breaks the chain.
Next: Day 18 — User-defined functions: scalar, inline table-valued and multi-statement table-valued functions, and their performance differences.