T-SQL batches, variables and control flow
T-SQL as a programming language: batches and GO, variables and how far they reach, the difference between SET and SELECT assignment, PRINT, IF … ELSE, WHILE loops with BREAK, and table variables versus temporary tables.
Variables
DECLARE creates a variable. Its name starts with @, it has a data type, and it can be given a starting value. Without one it starts as NULL.
DECLARE @MinPrice decimal(10,2) = 10.00; SELECT Name, PriceFROM dbo.ProductsWHERE Price >= @MinPriceORDER BY Price;Name Price ------------------- ----- Wood screws (200) 11.90 Paint roller set 12.50 Claw hammer 18.50 Plywood sheet 18 mm 42.00 Masonry paint 10 L 54.00 Cordless drill 89.00
Batches and GO
GO is not T-SQL at all: SSMS (and sqlcmd) look for lines that say GO, cut the script there, and send the pieces one after another. Each batch is compiled and run on its own, and a variable lives only until the end of its batch:
DECLARE @City nvarchar(40) = N'Leeds';SELECT COUNT(*) AS Customers FROM dbo.Customers WHERE City = @City;GOSELECT COUNT(*) AS Customers FROM dbo.Customers WHERE City = @City;Customers
---------
3
Msg 137, Level 15, State 2, Line 1
Must declare the scalar variable "@City".PRINT N'Creating the view…';CREATE OR ALTER VIEW dbo.LeedsCustomersASSELECT FirstName, LastName FROM dbo.Customers WHERE City = N'Leeds';Msg 111, Level 15, State 1, Line 2 'CREATE VIEW' must be the first statement in a query batch.
SET or SELECT to assign
SET assigns one variable. SELECT can assign several at once, straight from a query:
DECLARE @Products int, @AvgPrice decimal(10,2); SELECT @Products = COUNT(*), @AvgPrice = AVG(Price)FROM dbo.Products; PRINT CONCAT(N'Products: ', @Products, N', average price: ', @AvgPrice);Products: 12, average price: 22.25
PRINT sends a line of text to the Messages tab instead of returning a result set. It is useful for following what a script does. Build the text with CONCAT, because + tries to turn the text into a number as soon as one side is a number:
DECLARE @Products int = 12;PRINT N'Products: ' + @Products;Msg 245, Level 16, State 1, Line 2 Conversion failed when converting the nvarchar value 'Products: ' to data type int.
DECLARE @Name nvarchar(60) = N'(nothing yet)'; SELECT @Name = Name FROM dbo.Products WHERE Price > 1000; -- no rowsSELECT @Name AS AfterSelect; SET @Name = (SELECT Name FROM dbo.Products WHERE Price > 1000);SELECT @Name AS AfterSet; SET @Name = (SELECT Name FROM dbo.Products WHERE CategoryId = 1); -- three rowsAfterSelect ------------- (nothing yet) AfterSet -------- NULL Msg 512, Level 16, State 1, Line 9 Subquery returned more than 1 value. This is not permitted when the subquery follows =, !=, <, <= , >, >= or when the subquery is used as an expression.
| Query returns | `SELECT @v = col …` | `SET @v = (SELECT col …)` |
|---|---|---|
IF … ELSE
IF runs the next statement only when its condition is true. ELSE gives the other path, and ELSE IF chains more tests:
DECLARE @ProductId int = 9, @Stock int; SELECT @Stock = Stock FROM dbo.Products WHERE ProductId = @ProductId; IF @Stock = 0 PRINT N'Out of stock: reorder now';ELSE IF @Stock < 20 PRINT N'Low stock';ELSE PRINT N'In stock';Out of stock: reorder now
BEGIN … END:
IF 1 = 0 PRINT N'A: inside the IF'; PRINT N'B: looks inside, but always runs'; IF 1 = 0BEGIN PRINT N'C: inside the IF'; PRINT N'D: inside the IF too';END;B: looks inside, but always runs
IF EXISTS (subquery) is the usual way to test for rows. It stops reading as soon as one row is found:
IF EXISTS (SELECT 1 FROM dbo.Orders WHERE Status = 'Pending') SELECT OrderId, CustomerId, OrderDate FROM dbo.Orders WHERE Status = 'Pending';ELSE PRINT N'No pending orders';OrderId CustomerId OrderDate
------- ---------- ----------
8 8 2024-05-18
9 2 2024-06-01WHILE loops
WHILE repeats a statement, usually a BEGIN … END block, as long as its condition is true. BREAK leaves the loop and CONTINUE jumps back to the test. This loop walks through the out-of-stock products one at a time, in ProductId order:
DECLARE @Id int = 0, @Name nvarchar(60); WHILE 1 = 1BEGIN SELECT TOP (1) @Id = ProductId, @Name = Name FROM dbo.Products WHERE ProductId > @Id AND Stock = 0 ORDER BY ProductId; IF @@ROWCOUNT = 0 BREAK; -- nothing found: we are done PRINT CONCAT(N'Reorder #', @Id, N': ', @Name);END;Reorder #9: Wall plugs (100) Reorder #12: Safety goggles
@@ROWCOUNT holds the number of rows the previous statement affected. Here it detects the “no rows” case that would otherwise leave @Id unchanged and loop for ever.
Table variables and temporary tables
#name) is a real table in tempdb that lasts until your session ends or you drop it:
DECLARE @Low TABLE (ProductId int PRIMARY KEY, Name nvarchar(60), Stock int); INSERT INTO @Low (ProductId, Name, Stock)SELECT ProductId, Name, Stock FROM dbo.Products WHERE Stock < 30; SELECT * FROM @Low ORDER BY Stock;ProductId Name Stock
--------- ------------------ -----
9 Wall plugs (100) 0
12 Safety goggles 0
2 Cordless drill 12
10 Masonry paint 10 L 25DECLARE @Log TABLE (Msg nvarchar(50));CREATE TABLE #Log (Msg nvarchar(50)); BEGIN TRANSACTION; INSERT INTO @Log VALUES (N'kept by the table variable'); INSERT INTO #Log VALUES (N'kept by the temp table');ROLLBACK; SELECT (SELECT COUNT(*) FROM @Log) AS TableVariableRows, (SELECT COUNT(*) FROM #Log) AS TempTableRows; DROP TABLE #Log;TableVariableRows TempTableRows
----------------- -------------
1 0| Table variable `@t` | Temp table `#t` | |
|---|---|---|
Show a hint
Show the solution
DECLARE @CustomerId int = 2, @Name nvarchar(40), @Orders int; SELECT @Name = FirstName FROM dbo.Customers WHERE CustomerId = @CustomerId;SELECT @Orders = COUNT(*) FROM dbo.Orders WHERE CustomerId = @CustomerId; IF @Orders = 0 PRINT N'No orders';ELSE PRINT CONCAT(@Name, N' has ', @Orders, N' orders');Ben has 2 orders
Remember
DECLARE @name type = value creates a variable; without a value it starts as NULL. GO is an SSMS command, not T-SQL: it splits the script into batches that are compiled and run separately. Variables last only until the end of their batch (Msg 137 after GO). CREATE VIEW, PROCEDURE, FUNCTION and TRIGGER must be the first statement in their batch (Msg 111). SELECT @v = … leaves the variable unchanged when no rows come back; SET @v = (subquery) gives NULL, and fails on several rows. Build PRINT messages with CONCAT; + with a number tries to convert the text. IF and WHILE control one statement: use BEGIN … END for several. BREAK leaves a loop; @@ROWCOUNT tells you if the last statement found rows. Table variables end with the batch, have no statistics and ignore ROLLBACK; temp tables are real tables in tempdb.