Loading content...
Standard SQL is declarative—you describe the result you want, and the database figures out how to get it. Stored procedures transcend this limitation by adding procedural capabilities: the ability to make decisions, branch execution paths, repeat operations, and iterate through data row by row.
Control flow transforms stored procedures from simple script containers into genuine programs that can handle complex business logic, process data conditionally, and perform operations that pure SQL cannot express.
By the end of this page, you will master conditional statements (IF/ELSE, CASE), loop constructs (WHILE, FOR, LOOP), iteration patterns including cursors, control transfer statements (RETURN, BREAK, CONTINUE), and when to use procedural logic versus set-based operations.
IF/ELSE statements enable conditional execution—running different code based on whether a condition is true or false. This is the fundamental decision-making construct in procedural SQL.
Structure:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
-- Basic IF/ELSE syntax in T-SQLCREATE PROCEDURE Pricing.ApplyDiscount @OrderID INT, @CustomerTier VARCHAR(20), @OrderTotal DECIMAL(18,2), @DiscountedTotal DECIMAL(18,2) OUTPUT, @DiscountApplied DECIMAL(5,2) OUTPUTASBEGIN SET NOCOUNT ON; -- Simple IF IF @OrderTotal <= 0 BEGIN SET @DiscountedTotal = 0; SET @DiscountApplied = 0; RETURN; END -- IF/ELSE IF/ELSE chain IF @CustomerTier = 'Platinum' AND @OrderTotal >= 500 BEGIN SET @DiscountApplied = 20.00; -- 20% for Platinum with $500+ orders END ELSE IF @CustomerTier = 'Platinum' BEGIN SET @DiscountApplied = 15.00; -- 15% for Platinum under $500 END ELSE IF @CustomerTier = 'Gold' AND @OrderTotal >= 1000 BEGIN SET @DiscountApplied = 15.00; -- 15% for Gold with $1000+ orders END ELSE IF @CustomerTier = 'Gold' BEGIN SET @DiscountApplied = 10.00; -- 10% for Gold under $1000 END ELSE IF @CustomerTier = 'Silver' BEGIN SET @DiscountApplied = 5.00; -- 5% for Silver END ELSE BEGIN SET @DiscountApplied = 0; -- No discount for others END -- Calculate final total SET @DiscountedTotal = @OrderTotal * (1 - @DiscountApplied / 100); -- Nested IF for additional rules IF @DiscountedTotal > 0 AND @DiscountApplied > 0 BEGIN -- Log significant discounts for audit IF @DiscountApplied >= 15 BEGIN INSERT INTO Audit.DiscountLog (OrderID, DiscountPercent, LogDate) VALUES (@OrderID, @DiscountApplied, GETDATE()); END ENDEND;GO -- Note: T-SQL uses BEGIN/END for multi-statement blocks-- Single statements don't require BEGIN/END:-- IF @x > 0-- SELECT 'Positive'; -- Single statement, no BEGIN neededUse IF/ELSE when executing different statements based on conditions. Use CASE when selecting different values within a single expression. IF controls flow; CASE selects values.
CASE provides conditional logic within expressions. Unlike IF which controls statement execution, CASE evaluates to a value. There are two forms:
Simple CASE:
CASE expression
WHEN value1 THEN result1
WHEN value2 THEN result2
ELSE default_result
END
Searched CASE:
CASE
WHEN condition1 THEN result1
WHEN condition2 THEN result2
ELSE default_result
END
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
-- CASE in stored proceduresCREATE PROCEDURE Reports.GenerateStatusReport @ReportType VARCHAR(20), @StartDate DATE, @EndDate DATEASBEGIN SET NOCOUNT ON; -- Simple CASE for report type routing DECLARE @ReportTitle VARCHAR(100); SET @ReportTitle = CASE @ReportType WHEN 'DAILY' THEN 'Daily Operations Report' WHEN 'WEEKLY' THEN 'Weekly Summary Report' WHEN 'MONTHLY' THEN 'Monthly Executive Report' WHEN 'QUARTERLY' THEN 'Quarterly Business Review' ELSE 'Custom Date Range Report' END; -- Searched CASE for status categorization SELECT o.OrderID, o.OrderDate, o.TotalAmount, o.Status, -- Searched CASE for status category CASE WHEN o.Status = 'Pending' AND DATEDIFF(DAY, o.OrderDate, GETDATE()) > 7 THEN 'Overdue' WHEN o.Status = 'Pending' THEN 'Active' WHEN o.Status IN ('Shipped', 'Delivered') THEN 'Completed' WHEN o.Status IN ('Cancelled', 'Refunded') THEN 'Closed' ELSE 'Unknown' END AS StatusCategory, -- Searched CASE for priority calculation CASE WHEN o.TotalAmount >= 10000 THEN 'Critical' WHEN o.TotalAmount >= 5000 THEN 'High' WHEN o.TotalAmount >= 1000 THEN 'Medium' ELSE 'Standard' END AS Priority, -- Nested CASE expressions CASE @ReportType WHEN 'DAILY' THEN CASE WHEN o.OrderDate = CAST(GETDATE() AS DATE) THEN 'Today' ELSE 'Previous' END WHEN 'WEEKLY' THEN CASE WHEN o.OrderDate >= DATEADD(DAY, -7, GETDATE()) THEN 'This Week' ELSE 'Earlier' END ELSE 'In Range' END AS TimePeriod FROM Sales.Orders o WHERE o.OrderDate BETWEEN @StartDate AND @EndDate ORDER BY o.OrderDate DESC; -- CASE in conditional assignment DECLARE @SummaryLevel VARCHAR(20); SET @SummaryLevel = CASE WHEN DATEDIFF(DAY, @StartDate, @EndDate) > 90 THEN 'Monthly' WHEN DATEDIFF(DAY, @StartDate, @EndDate) > 14 THEN 'Weekly' ELSE 'Daily' END; SELECT @ReportTitle AS Title, @SummaryLevel AS Granularity;END;GOWHILE loops execute a block of code repeatedly while a condition remains true. They're essential for iterative processing, batch operations, and situations where the number of iterations isn't known in advance.
Basic structure:
WHILE condition
BEGIN
-- statements
-- Must include logic to eventually make condition false!
END
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
-- WHILE loop examplesCREATE PROCEDURE DataMaintenance.ArchiveOldRecords @DaysToKeep INT = 365, @BatchSize INT = 1000, @MaxBatches INT = 100, -- Safety limit @RecordsArchived INT OUTPUT, @BatchesProcessed INT OUTPUTASBEGIN SET NOCOUNT ON; DECLARE @CutoffDate DATE = DATEADD(DAY, -@DaysToKeep, GETDATE()); DECLARE @CurrentBatch INT = 0; DECLARE @RowsAffected INT = 1; -- Initialize to enter loop SET @RecordsArchived = 0; SET @BatchesProcessed = 0; -- WHILE loop with multiple exit conditions WHILE @RowsAffected > 0 AND @CurrentBatch < @MaxBatches BEGIN SET @CurrentBatch = @CurrentBatch + 1; BEGIN TRY BEGIN TRANSACTION; -- Archive batch to archive table INSERT INTO Archive.TransactionHistory (TransactionID, TransactionDate, Amount, Details, ArchivedDate) SELECT TOP (@BatchSize) TransactionID, TransactionDate, Amount, Details, GETDATE() FROM Operations.TransactionHistory WHERE TransactionDate < @CutoffDate AND TransactionID NOT IN ( SELECT TransactionID FROM Archive.TransactionHistory ); SET @RowsAffected = @@ROWCOUNT; -- Delete from source DELETE FROM Operations.TransactionHistory WHERE TransactionID IN ( SELECT TOP (@BatchSize) TransactionID FROM Operations.TransactionHistory WHERE TransactionDate < @CutoffDate ); COMMIT TRANSACTION; SET @RecordsArchived = @RecordsArchived + @RowsAffected; SET @BatchesProcessed = @CurrentBatch; -- Optional: Small delay to reduce lock contention WAITFOR DELAY '00:00:00.100'; -- 100ms END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; -- Log error but continue processing INSERT INTO Logs.ErrorLog (ErrorMessage, ErrorDate, ProcedureName) VALUES (ERROR_MESSAGE(), GETDATE(), 'ArchiveOldRecords'); END CATCH END -- Final status IF @CurrentBatch >= @MaxBatches BEGIN INSERT INTO Logs.Info (Message, LogDate) VALUES ('Archive reached max batches - more records may remain', GETDATE()); ENDEND;GO -- Counter-based WHILE loopCREATE PROCEDURE Reports.GenerateDailyReportsForRange @StartDate DATE, @EndDate DATEASBEGIN SET NOCOUNT ON; DECLARE @CurrentDate DATE = @StartDate; DECLARE @DaysProcessed INT = 0; -- Loop through each day WHILE @CurrentDate <= @EndDate BEGIN -- Generate report for current date INSERT INTO Reports.DailySummary (ReportDate, TotalOrders, TotalRevenue, GeneratedAt) SELECT @CurrentDate, COUNT(*), SUM(TotalAmount), GETDATE() FROM Sales.Orders WHERE CAST(OrderDate AS DATE) = @CurrentDate; -- Move to next day SET @CurrentDate = DATEADD(DAY, 1, @CurrentDate); SET @DaysProcessed = @DaysProcessed + 1; END SELECT @DaysProcessed AS DaysProcessed;END;GOAlways ensure loop exit conditions will eventually be met. Include iteration counters as safety limits, timeout checks for time-based loops, and proper handling of empty result sets. An infinite loop can lock tables and crash systems.
FOR loops (in PostgreSQL and Oracle) and cursors (all databases) enable row-by-row processing of query results. While set-based operations are generally preferred, cursors are necessary when:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
-- Explicit cursor in SQL ServerCREATE PROCEDURE Notifications.SendOrderConfirmations @BatchSize INT = 100, @SentCount INT OUTPUT, @FailedCount INT OUTPUTASBEGIN SET NOCOUNT ON; -- Cursor variables DECLARE @OrderID INT; DECLARE @CustomerEmail VARCHAR(255); DECLARE @CustomerName VARCHAR(200); DECLARE @OrderTotal DECIMAL(18,2); SET @SentCount = 0; SET @FailedCount = 0; -- Declare cursor DECLARE order_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT TOP (@BatchSize) o.OrderID, c.Email, c.FirstName + ' ' + c.LastName, o.TotalAmount FROM Sales.Orders o JOIN Customers c ON o.CustomerID = c.CustomerID WHERE o.ConfirmationSent = 0 AND o.Status = 'Confirmed' ORDER BY o.OrderDate; -- Open cursor OPEN order_cursor; -- Fetch first row FETCH NEXT FROM order_cursor INTO @OrderID, @CustomerEmail, @CustomerName, @OrderTotal; -- Process rows WHILE @@FETCH_STATUS = 0 BEGIN BEGIN TRY -- Call email procedure for each order EXEC Communications.SendEmail @ToAddress = @CustomerEmail, @Subject = 'Order Confirmation', @TemplateName = 'OrderConfirmation', @TemplateData = @OrderID; -- Mark as sent UPDATE Sales.Orders SET ConfirmationSent = 1, ConfirmationDate = GETDATE() WHERE OrderID = @OrderID; SET @SentCount = @SentCount + 1; END TRY BEGIN CATCH -- Log failure INSERT INTO Logs.EmailFailures (OrderID, Email, Error, AttemptDate) VALUES (@OrderID, @CustomerEmail, ERROR_MESSAGE(), GETDATE()); SET @FailedCount = @FailedCount + 1; END CATCH -- Fetch next row FETCH NEXT FROM order_cursor INTO @OrderID, @CustomerEmail, @CustomerName, @OrderTotal; END -- Cleanup CLOSE order_cursor; DEALLOCATE order_cursor;END;GO -- Cursor with UPDATE WHERE CURRENT OFCREATE PROCEDURE Inventory.ApplyPriceAdjustments @AdjustmentType VARCHAR(20), @AdjustmentPercent DECIMAL(5,2)ASBEGIN SET NOCOUNT ON; DECLARE @ProductID INT; DECLARE @CurrentPrice DECIMAL(18,2); DECLARE @NewPrice DECIMAL(18,2); -- Cursor for update DECLARE price_cursor CURSOR LOCAL FOR SELECT ProductID, UnitPrice FROM Products WHERE Category = @AdjustmentType AND Active = 1 FOR UPDATE OF UnitPrice; -- Lock for update OPEN price_cursor; FETCH NEXT FROM price_cursor INTO @ProductID, @CurrentPrice; WHILE @@FETCH_STATUS = 0 BEGIN -- Calculate new price with business rules SET @NewPrice = @CurrentPrice * (1 + @AdjustmentPercent / 100); -- Ensure minimum price IF @NewPrice < 0.99 SET @NewPrice = 0.99; -- Round to nearest 0.99 SET @NewPrice = ROUND(@NewPrice, 0) - 0.01; -- Update using cursor position UPDATE Products SET UnitPrice = @NewPrice, LastPriceUpdate = GETDATE() WHERE CURRENT OF price_cursor; -- Log change INSERT INTO Audit.PriceChanges (ProductID, OldPrice, NewPrice, ChangeDate) VALUES (@ProductID, @CurrentPrice, @NewPrice, GETDATE()); FETCH NEXT FROM price_cursor INTO @ProductID, @CurrentPrice; END CLOSE price_cursor; DEALLOCATE price_cursor;END;GOCursors process row-by-row, which is inherently slower than set-based operations. A single UPDATE with WHERE clause is almost always faster than a cursor that updates each row. Use cursors only when set-based logic truly cannot express your requirements.
Control transfer statements alter the normal flow of execution by jumping out of loops or procedures entirely:
| Action | SQL Server | PostgreSQL | MySQL | Oracle |
|---|---|---|---|---|
| Exit loop | BREAK | EXIT | LEAVE loop_label | EXIT |
| Skip iteration | CONTINUE | CONTINUE | ITERATE loop_label | CONTINUE |
| Exit procedure | RETURN | RETURN | LEAVE (or just end) | RETURN |
| Return value | RETURN value | N/A (use INOUT) | N/A | N/A (procedures) |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
-- BREAK, CONTINUE, and RETURN examplesCREATE PROCEDURE Processing.ProcessWithControlFlow @MaxItems INT, @SkipPriority VARCHAR(20) = NULL, @StopOnFirstError BIT = 1, @ProcessedCount INT OUTPUT, @SkippedCount INT OUTPUT, @ErrorCount INT OUTPUT, @ReturnCode INT OUTPUTASBEGIN SET NOCOUNT ON; DECLARE @ItemID INT; DECLARE @Priority VARCHAR(20); DECLARE @ItemCount INT = 0; SET @ProcessedCount = 0; SET @SkippedCount = 0; SET @ErrorCount = 0; SET @ReturnCode = 0; -- Cursor for items DECLARE item_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT ItemID, Priority FROM Processing.Items WHERE Status = 'Pending' ORDER BY Priority DESC; OPEN item_cursor; FETCH NEXT FROM item_cursor INTO @ItemID, @Priority; WHILE @@FETCH_STATUS = 0 BEGIN SET @ItemCount = @ItemCount + 1; -- BREAK: Exit loop if max reached IF @ItemCount > @MaxItems BEGIN SET @ReturnCode = 1; -- Partial completion BREAK; -- Exit the WHILE loop END -- CONTINUE: Skip items with specified priority IF @Priority = @SkipPriority BEGIN SET @SkippedCount = @SkippedCount + 1; FETCH NEXT FROM item_cursor INTO @ItemID, @Priority; CONTINUE; -- Skip to next iteration END -- Process the item BEGIN TRY EXEC Processing.ProcessSingleItem @ItemID; SET @ProcessedCount = @ProcessedCount + 1; END TRY BEGIN CATCH SET @ErrorCount = @ErrorCount + 1; -- BREAK on error if configured IF @StopOnFirstError = 1 BEGIN SET @ReturnCode = -1; -- Error exit BREAK; END -- Otherwise CONTINUE to next item (implicit) END CATCH FETCH NEXT FROM item_cursor INTO @ItemID, @Priority; END CLOSE item_cursor; DEALLOCATE item_cursor; -- RETURN with status code IF @ErrorCount > 0 AND @ReturnCode = 0 SET @ReturnCode = 2; -- Completed with errors RETURN @ReturnCode; -- Exit procedure with valueEND;GO -- Calling and handling return codeDECLARE @Processed INT, @Skipped INT, @Errors INT, @Result INT; EXEC @Result = Processing.ProcessWithControlFlow @MaxItems = 50, @SkipPriority = 'Low', @StopOnFirstError = 0, @ProcessedCount = @Processed OUTPUT, @SkippedCount = @Skipped OUTPUT, @ErrorCount = @Errors OUTPUT, @ReturnCode = @Result OUTPUT; SELECT @Result AS ReturnCode, CASE @Result WHEN 0 THEN 'Success' WHEN 1 THEN 'Partial - reached max' WHEN 2 THEN 'Completed with errors' WHEN -1 THEN 'Stopped on error' END AS Status, @Processed AS Processed, @Skipped AS Skipped, @Errors AS Errors;Effective control flow makes procedures readable, maintainable, and performant. These best practices help avoid common pitfalls:
IF invalid RETURN is cleaner than IF valid BEGIN ... long code ... END.<<loop_name>>) for clarity when nesting or using EXIT/CONTINUE.1234567891011121314151617181920212223242526272829303132333435
-- ❌ BAD: Deep nesting, no early returnIF @CustomerID IS NOT NULLBEGIN IF EXISTS (SELECT 1 FROM Customers WHERE CustomerID = @CustomerID) BEGIN IF @Amount > 0 BEGIN IF @Amount <= @Balance BEGIN -- Finally, the actual logic -- buried in 4 levels of nesting UPDATE Accounts SET Balance = Balance - @Amount WHERE CustomerID = @CustomerID; END ELSE BEGIN SET @Error = 'Insufficient funds'; END END ELSE BEGIN SET @Error = 'Invalid amount'; END END ELSE BEGIN SET @Error = 'Customer not found'; ENDENDELSEBEGIN SET @Error = 'Customer ID required';END1234567891011121314151617181920212223242526272829303132
-- ✅ GOOD: Early returns, flat structureIF @CustomerID IS NULLBEGIN SET @Error = 'Customer ID required'; RETURN;END IF NOT EXISTS (SELECT 1 FROM Customers WHERE CustomerID = @CustomerID)BEGIN SET @Error = 'Customer not found'; RETURN;END IF @Amount <= 0BEGIN SET @Error = 'Invalid amount'; RETURN;END IF @Amount > @BalanceBEGIN SET @Error = 'Insufficient funds'; RETURN;END -- Happy path: clear and unindentedUPDATE AccountsSET Balance = Balance - @AmountWHERE CustomerID = @CustomerID; SET @Error = NULL; -- SuccessWe've covered the essential control flow constructs for stored procedures. Here are the key takeaways:
What's next:
Control flow handles the happy path, but real-world procedures must handle errors gracefully. The next page covers error handling—TRY/CATCH, exception handlers, and patterns for robust procedure development.
You now understand how to control execution flow in stored procedures using conditionals, loops, and control transfer statements. Next, we'll learn to handle errors and exceptions for production-ready procedures.