<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Programmability
All lessons in this course

Lesson 37 of 54 17 minAdvanced

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.

So far every script has been a list of queries. T-SQL, SQL Server’s dialect of SQL, is also a small programming language: it has variables, IF and WHILE. They are what stored procedures, functions and triggers are made of, so this lesson comes first.

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.

Query.sql
DECLARE @MinPrice decimal(10,2) = 10.00; SELECT Name, PriceFROM dbo.ProductsWHERE Price >= @MinPriceORDER BY Price;
Output
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

A batch is the block of text that SSMS sends to SQL Server in one 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:

Query.sql
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;
Output
Customers
---------
        3

Msg 137, Level 15, State 2, Line 1
Must declare the scalar variable "@City".

Some statements must be the first statement in their batch: CREATE VIEW, CREATE PROCEDURE, CREATE FUNCTION and CREATE TRIGGER. That is why the previous lesson put GO after each view. Without it, the whole batch fails to compile, so even the PRINT before the CREATE never runs:

Query.sql
PRINT N'Creating the view…';CREATE OR ALTER VIEW dbo.LeedsCustomersASSELECT FirstName, LastName FROM dbo.Customers WHERE City = N'Leeds';
Output
Msg 111, Level 15, State 1, Line 2
'CREATE VIEW' must be the first statement in a query batch.
Interactive

Where do the batches end?

Click a gap to add or remove a GO. Each colour is one batch.

1DECLARE @City nvarchar(40) = N'Leeds';
2SELECT COUNT(*) AS Customers FROM dbo.Customers WHERE City = @City;
3CREATE OR ALTER VIEW dbo.LeedsCustomers AS SELECT FirstName, LastName FROM dbo.Customers WHERE City = N'Leeds';
4SELECT COUNT(*) AS InView FROM dbo.LeedsCustomers;
5PRINT CONCAT(N'Done for ', @City);
  1. Batch 1lines 1–5Msg 111: CREATE VIEW is not the first statement, so the whole batch is rejected. Nothing in it runs.
What SSMS shows
Msg 111, Level 15, State 1, Line 3
'CREATE VIEW' must be the first statement in a query batch.
One batch: SQL Server rejects all of it because CREATE VIEW is not first. Add a GO before line 3.

SET or SELECT to assign

There are two ways to put a value into a variable. SET assigns one variable. SELECT can assign several at once, straight from a query:

Query.sql
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);
Output
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:

Query.sql
DECLARE @Products int = 12;PRINT N'Products: ' + @Products;
Output
Msg 245, Level 16, State 1, Line 2
Conversion failed when converting the nvarchar value 'Products: ' to data type int.

The two forms of assignment behave differently when the query returns no rows, or more than one:

Query.sql
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 rows
Output
AfterSelect
-------------
(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 …)`
one rowthe valuethe value
no rowsunchanged: keeps its old valueNULL
several rowsone of them, with no error (which one is not defined)error Msg 512

IF … ELSE

IF runs the next statement only when its condition is true. ELSE gives the other path, and ELSE IF chains more tests:

Query.sql
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';
Output
Out of stock: reorder now

IF controls one statement. Indenting the next line does not make it part of the IF. To run several statements, wrap them in BEGIN … END:

Query.sql
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;
Output
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:

Query.sql
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';
Output
OrderId  CustomerId  OrderDate
-------  ----------  ----------
      8           8  2024-05-18
      9           2  2024-06-01

WHILE 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:

Query.sql
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;
Output
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

A table variable is declared like a variable and used like a table. A temporary table (#name) is a real table in tempdb that lasts until your session ends or you drop it:

Query.sql
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;
Output
ProductId  Name                Stock
---------  ------------------  -----
        9  Wall plugs (100)        0
       12  Safety goggles          0
        2  Cordless drill         12
       10  Masonry paint 10 L     25

They look alike but behave differently. A table variable ends with its batch, has no statistics (so the optimizer guesses its row count), and, surprisingly, ignores ROLLBACK:

Query.sql
DECLARE @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;
Output
TableVariableRows  TempTableRows
-----------------  -------------
                1              0
Table variable `@t`Temp table `#t`
Lives untilthe end of the batchthe end of the session, or DROP
Statisticsnone: estimates are guessesyes: good plans for big sets
ROLLBACKignoredundone like any table
Indexesinline PRIMARY KEY / UNIQUE / INDEXany index, added any time
Good forsmall sets, keeping a log through a rollbacklarger intermediate results
Quick check

SELECT @Total = Price FROM dbo.Products WHERE ProductId = 999; finds no product. @Total held 5.00 before. What does it hold after?

Quick check

Why does a variable declared before GO not exist after it?

Practice

Using variables and IF, look up the number of orders for the customer with CustomerId 2. If there are none, PRINT “No orders”. Otherwise PRINT a line like “Ben has 2 orders”, using the customer’s first name.

Show a hint

SELECT both values into variables first: @Name from Customers and @Orders with COUNT(*) from Orders. COUNT(*) always returns a row, so @Orders is never left unchanged. Build the message with CONCAT.

Show the solution
Solution.sql
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');
Output
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.
0 of 54 lessons completed