T-SQL Basics
T-SQL (Transact-SQL) is Microsoft SQL Server’s extension of standard SQL. It adds variables, control flow, error handling, procedural logic, and server-side objects — essential for stored procedures, ETL, and complex reporting.
Batches and GO
T-SQL executes in batches separated by GO (a client directive, not T-SQL):
USE MyApp;
GO
DECLARE @x INT = 1; -- Must be first in batch
SELECT @x;
GO
-- New batch — @x is out of scope here
GO 10 repeats the batch 10 times (SSMS/sqlcmd only).
Variables and Assignment
DECLARE @user_id INT = 42;
DECLARE @name NVARCHAR(100);
DECLARE @total DECIMAL(18,2);
SELECT @name = Name, @total = Balance
FROM dbo.Accounts
WHERE AccountID = @user_id;
SELECT @user_id AS user_id, @name AS name, @total AS balance;
Use SET for single assignment; SELECT can assign multiple variables in one statement.
Conditional Logic
DECLARE @stock INT = 5;
IF @stock > 0
PRINT N'In stock';
ELSE
PRINT N'Out of stock';
IF @stock > 100
SELECT N'High' AS level;
ELSE IF @stock > 0
SELECT N'Low' AS level;
ELSE
SELECT N'Empty' AS level;
PRINT outputs to Messages tab — not returned to application clients. Use SELECT for result sets.
WHILE Loops
DECLARE @i INT = 1;
WHILE @i <= 5
BEGIN
INSERT INTO dbo.AuditLog (Message)
VALUES (N'Iteration ' + CAST(@i AS NVARCHAR(10)));
SET @i = @i + 1;
END;
Avoid row-by-row loops on large datasets — set-based operations scale better.
CASE Expressions
SELECT
Name,
Age,
CASE
WHEN Age < 18 THEN N'Minor'
WHEN Age < 65 THEN N'Adult'
ELSE N'Senior'
END AS age_group,
CASE Status
WHEN N'A' THEN N'Active'
WHEN N'I' THEN N'Inactive'
ELSE N'Unknown'
END AS status_label
FROM dbo.Users;
Simple CASE matches equality; searched CASE evaluates predicates.
Common Functions
SELECT
SYSUTCDATETIME() AS utc_now,
NEWID() AS guid,
ISNULL(Email, N'N/A') AS email,
LEN(Name) AS name_len,
LEFT(Name, 10) AS name_prefix,
CONCAT(FirstName, N' ', LastName) AS full_name,
FORMAT(CreatedAt, N'yyyy-MM-dd') AS created_date
FROM dbo.Users;
| Category | Examples |
|---|---|
| Date/time | GETDATE(), DATEADD, DATEDIFF, EOMONTH |
| String | TRIM, STRING_SPLIT, REPLACE, CHARINDEX |
| Conversion | CAST, CONVERT, TRY_CAST, TRY_CONVERT |
| Aggregate | COUNT, SUM, AVG, STRING_AGG (2017+) |
TRY/CATCH Error Handling
BEGIN TRY
BEGIN TRANSACTION;
UPDATE dbo.Accounts SET Balance = Balance - 100 WHERE AccountID = 1;
UPDATE dbo.Accounts SET Balance = Balance + 100 WHERE AccountID = 2;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
SELECT
ERROR_NUMBER() AS error_number,
ERROR_SEVERITY() AS severity,
ERROR_STATE() AS state,
ERROR_PROCEDURE() AS procedure_name,
ERROR_LINE() AS line,
ERROR_MESSAGE() AS message;
END CATCH;
Use THROW; (SQL Server 2012+) to re-raise without losing original error context.
Table Variables vs Temp Tables
-- Table variable — statistics limited, good for small sets
DECLARE @recent TABLE (
OrderID INT PRIMARY KEY,
Total DECIMAL(18,2)
);
INSERT INTO @recent (OrderID, Total)
SELECT TOP 10 OrderID, Total
FROM dbo.Orders
ORDER BY CreatedAt DESC;
SELECT * FROM @recent;
-- Temp table — statistics, indexes, better for larger intermediate results
CREATE TABLE #staging (
ProductID INT,
Qty INT,
INDEX IX_product (ProductID)
);
INSERT INTO #staging
SELECT ProductID, SUM(Quantity)
FROM dbo.OrderItems
GROUP BY ProductID;
SELECT * FROM #staging;
DROP TABLE #staging;
| Feature | Table Variable | Temp Table (#) |
|---|---|---|
| Statistics | Row estimate often 1 | Full statistics |
| Indexes | PK only (inline since 2014) | Full index support |
| Scope | Batch/procedure | Session |
| Best for | Small lookups | Large intermediate sets |
Cursors (Use Sparingly)
DECLARE @id INT, @name NVARCHAR(100);
DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
SELECT CustomerID, Name FROM dbo.Customers WHERE Active = 1;
OPEN cur;
FETCH NEXT FROM cur INTO @id, @name;
WHILE @@FETCH_STATUS = 0
BEGIN
-- Prefer set-based logic instead
FETCH NEXT FROM cur INTO @id, @name;
END;
CLOSE cur;
DEALLOCATE cur;
Cursors serialize work — refactor to JOINs or window functions when possible.
Dynamic SQL
DECLARE @table NVARCHAR(128) = N'dbo.Orders';
DECLARE @sql NVARCHAR(MAX) = N'SELECT COUNT(*) FROM ' + QUOTENAME(@table) + N';';
EXEC sp_executesql @sql;
Always use sp_executesql with parameters — never concatenate user input directly (SQL injection risk).
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Missing SET NOCOUNT ON in procedures |
Extra DONE_IN_PROC messages to apps | Add at procedure start |
SELECT @var = col with multiple rows |
Undefined which row assigns | Use TOP 1 with ORDER BY |
Comparing NULL with = |
Never matches | Use IS NULL / IS NOT NULL |
| Implicit conversion in WHERE | Index scans | Match parameter type to column |
| Huge loops | Hours of runtime | Rewrite set-based |
Performance Tips
- Prefer set-based INSERT/UPDATE over cursors
- Use temp tables when intermediate row counts exceed ~100 rows
- Avoid scalar UDFs in SELECT lists (pre-2019 — forced serial plans)
- Mark read-only procedures when possible for optimization hints
Production Scenario: Idempotent Seed Script
MERGE dbo.Settings AS target
USING (VALUES (N'max_retries', N'3')) AS source (KeyName, Value)
ON target.KeyName = source.KeyName
WHEN MATCHED AND target.Value <> source.Value THEN
UPDATE SET Value = source.Value, UpdatedAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (KeyName, Value) VALUES (source.KeyName, source.Value);
Troubleshooting
| Issue | Diagnostic |
|---|---|
| Variable NULL unexpectedly | Assignment failed — no matching row |
| Batch errors on DECLARE | DECLARE must start batch |
| Wrong date results | datetime vs datetime2; timezone awareness |
| Procedure not found | Schema qualify: EXEC dbo.MyProc |
-- Find procedure definition
SELECT OBJECT_DEFINITION(OBJECT_ID(N'dbo.MyProc'));
T-SQL bridges querying and procedural logic — master it before diving into stored procedures, triggers, and advanced transaction control.