Stored Procedures and Functions
Stored procedures and functions encapsulate T-SQL on the server — reducing network round trips, centralizing validation, and controlling permissions. Balance database logic with application-layer testability.
Stored Procedure Basics
CREATE OR ALTER PROCEDURE dbo.GetUserOrders
@UserID INT,
@Status NVARCHAR(20) = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT OrderID, Total, Status, CreatedAt
FROM dbo.Orders
WHERE UserID = @UserID
AND (@Status IS NULL OR Status = @Status)
ORDER BY CreatedAt DESC;
END;
GO
EXEC dbo.GetUserOrders @UserID = 42;
EXEC dbo.GetUserOrders @UserID = 42, @Status = N'Paid';
Always SET NOCOUNT ON in procedures — prevents extra record count messages to clients.
Output Parameters
CREATE OR ALTER PROCEDURE dbo.CountActiveUsers
@Total INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SELECT @Total = COUNT(*) FROM dbo.Users WHERE Status = N'active';
END;
GO
DECLARE @count INT;
EXEC dbo.CountActiveUsers @Total = @count OUTPUT;
SELECT @count AS active_users;
Prefer result sets over OUTPUT for application APIs — easier mapping in ORMs.
Scalar Functions
CREATE OR ALTER FUNCTION dbo.fn_OrderTotal (@OrderID INT)
RETURNS DECIMAL(18,2)
AS
BEGIN
DECLARE @total DECIMAL(18,2);
SELECT @total = SUM(Quantity * UnitPrice)
FROM dbo.OrderItems
WHERE OrderID = @OrderID;
RETURN ISNULL(@total, 0);
END;
GO
SELECT OrderID, dbo.fn_OrderTotal(OrderID) AS total FROM dbo.Orders;
Scalar UDFs in SELECT historically caused serial plans (pre-2019). Prefer inline table-valued functions or compute in JOIN.
Inline Table-Valued Functions
CREATE OR ALTER FUNCTION dbo.fn_ActiveUsersInCountry (@Country NVARCHAR(50))
RETURNS TABLE
AS
RETURN (
SELECT UserID, Name, Email
FROM dbo.Users
WHERE Status = N'active' AND Country = @Country
);
GO
SELECT * FROM dbo.fn_ActiveUsersInCountry(N'US');
Inline TVFs merge into calling query — often optimize like views.
Multi-Statement TVFs
CREATE OR ALTER FUNCTION dbo.fn_TopCustomers (@MinOrders INT)
RETURNS @result TABLE (CustomerID INT, OrderCount INT)
AS
BEGIN
INSERT INTO @result
SELECT CustomerID, COUNT(*)
FROM dbo.Orders
GROUP BY CustomerID
HAVING COUNT(*) >= @MinOrders;
RETURN;
END;
Multi-statement TVFs have fixed cardinality estimates — use inline TVFs when possible.
Error Handling with TRY/CATCH
CREATE OR ALTER PROCEDURE dbo.SafeTransfer
@FromAccountID INT,
@ToAccountID INT,
@Amount DECIMAL(18,2)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
UPDATE dbo.Accounts SET Balance = Balance - @Amount
WHERE AccountID = @FromAccountID AND Balance >= @Amount;
IF @@ROWCOUNT = 0
THROW 50001, N'Insufficient funds or account not found', 1;
UPDATE dbo.Accounts SET Balance = Balance + @Amount
WHERE AccountID = @ToAccountID;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
SET XACT_ABORT ON rolls back on any error — consistent transaction state.
Parameter Sniffing
SQL Server caches execution plans based on first parameter values — skewed data causes bad plans:
-- Option 1: RECOMPILE at procedure level
CREATE OR ALTER PROCEDURE dbo.SearchOrders @Status NVARCHAR(20)
WITH RECOMPILE
AS
BEGIN
SET NOCOUNT ON;
SELECT * FROM dbo.Orders WHERE Status = @Status;
END;
-- Option 2: Statement-level hint
SELECT * FROM dbo.Orders WHERE Status = @Status OPTION (RECOMPILE);
-- Option 3: Optimize for unknown (legacy workaround)
CREATE OR ALTER PROCEDURE dbo.SearchOrders2 @Status NVARCHAR(20)
AS
BEGIN
SET NOCOUNT ON;
SELECT * FROM dbo.Orders WHERE Status = @Status
OPTION (OPTIMIZE FOR (@Status UNKNOWN));
END;
Use Query Store to detect plan regressions after deployment.
Dynamic SQL in Procedures
CREATE OR ALTER PROCEDURE dbo.SearchProducts
@NamePattern NVARCHAR(100),
@MinPrice DECIMAL(10,2) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @sql NVARCHAR(MAX) = N'
SELECT ProductID, Name, Price
FROM dbo.Products
WHERE Name LIKE @NamePattern';
IF @MinPrice IS NOT NULL
SET @sql = @sql + N' AND Price >= @MinPrice';
EXEC sp_executesql @sql,
N'@NamePattern NVARCHAR(100), @MinPrice DECIMAL(10,2)',
@NamePattern, @MinPrice;
END;
Never concatenate user input into SQL strings.
Triggers (Use Carefully)
CREATE OR ALTER TRIGGER dbo.tr_Orders_Audit
ON dbo.Orders
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.OrdersAudit (OrderID, Action, ChangedAt)
SELECT OrderID, N'INSERT', SYSUTCDATETIME() FROM inserted;
END;
Triggers hide logic — hard to debug, surprise performance cost. Prefer temporal tables or explicit audit in procedures.
Permissions
GRANT EXECUTE ON dbo.GetUserOrders TO app_api;
DENY SELECT ON dbo.Orders TO app_api; -- Force access through proc
Procedure execution with ownership chaining can bypass granular table checks — document security model.
Common Mistakes
| Mistake | Impact |
|---|---|
| Business logic only in procs | Hard to unit test |
SELECT * in procs |
Schema breaks clients |
| Missing error handling | Partial commits |
| Overusing scalar UDFs | Serial plans, slow reports |
No schema prefix dbo. |
Wrong object resolved |
Performance Tips
- Keep procedures set-based — no cursors on large sets
- Create covering indexes for procedure queries
- Use
OPTION (MAXDOP 1)for small point queries if parallelism hurts - Monitor with
sys.dm_exec_procedure_stats
SELECT
OBJECT_NAME(object_id) AS proc_name,
execution_count,
total_elapsed_time / execution_count AS avg_elapsed_us,
last_execution_time
FROM sys.dm_exec_procedure_stats
WHERE database_id = DB_ID()
ORDER BY total_elapsed_time DESC;
Production Scenario: Pagination Procedure
CREATE OR ALTER PROCEDURE dbo.GetOrdersPage
@PageSize INT = 20,
@LastOrderID INT = 0
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (@PageSize)
OrderID, CustomerID, Total, CreatedAt
FROM dbo.Orders
WHERE OrderID > @LastOrderID
ORDER BY OrderID;
END;
Keyset pagination scales for infinite scroll APIs.
When to Keep Logic in Application Code
- Complex business rules with frequent changes
- Logic requiring external APIs
- Heavy unit test coverage needs
Use procedures for data proximity, batch ETL, security boundaries, and stable high-volume queries.
Procedures centralize T-SQL — pair them with Query Store monitoring and version-controlled deployments for production reliability.