Dynamic SQL in SQL Server
Dynamic SQL = building a SQL statement as a STRING and executing it.
EXEC (@sql) -> simple, NO parameters (risky)
EXEC sys.sp_executesql @sql, N'@p INT', @p = 5 -> parameterized (SAFE + plan reuse)
Use it when table names, column names, sort order or the list of
filters are only known at run time.
GOLDEN RULES:
1) Pass VALUES as parameters (sp_executesql), never concatenate them.
2) For object names use QUOTENAME() and validate against sys tables.
3) PRINT @sql while developing to see the generated statement.
Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.
USE SqlLearningDb;
GO
Topic tables: yearly archive tables (same structure, different names)
DROP TABLE IF EXISTS dbo.OrdersArchive_2024;
DROP TABLE IF EXISTS dbo.OrdersArchive_2025;
SELECT * INTO dbo.OrdersArchive_2024 FROM dbo.Orders WHERE YEAR(OrderDate) = 2024;
SELECT * INTO dbo.OrdersArchive_2025 FROM dbo.Orders WHERE YEAR(OrderDate) = 2025;
GO
Messages(54 rows affected)
(26 rows affected)
Part A: EXEC vs sp_executesql
Simplest dynamic SQL
DECLARE @sql NVARCHAR(MAX) = N'SELECT TOP (5) ProductName, UnitPrice FROM dbo.Products ORDER BY UnitPrice DESC;';
EXEC (@sql);
GO
Output
| ProductName | UnitPrice |
| MacBook Air M3 | 120000.00 |
| HP Pavilion 14 | 85000.00 |
| iPhone 15 | 70000.00 |
| Dell Inspiron 15 | 65000.00 |
| Lenovo ThinkCentre | 55000.00 |
Parameterized with sp_executesql (correct way to pass values)
DECLARE @sql NVARCHAR(MAX) = N'
SELECT EmployeeID, FirstName, Salary
FROM dbo.Employees
WHERE DepartmentID = @DeptID AND Salary >= @MinSal;';
EXEC sys.sp_executesql
@sql,
N'@DeptID INT, @MinSal DECIMAL(10,2)', -- parameter definitions
@DeptID = 2, @MinSal = 50000; -- parameter values
GO
Output
| EmployeeID | FirstName | Salary |
| 2 | Anita | 180000.00 |
| 7 | Arjun | 100000.00 |
| 8 | Pooja | 55000.00 |
| 9 | Rahul | 52000.00 |
| 10 | Sneha | 58000.00 |
| 23 | Ritu | 55000.00 |
OUTPUT parameter from dynamic SQL
DECLARE @sql NVARCHAR(MAX) = N'SELECT @Cnt = COUNT(*) FROM dbo.Orders WHERE Status = @Status;';
DECLARE @Count INT;
EXEC sys.sp_executesql @sql, N'@Status VARCHAR(20), @Cnt INT OUTPUT', @Status = 'Delivered', @Cnt = @Count OUTPUT;
SELECT @Count AS DeliveredOrders;
GO
Part B: Dynamic Table Name (safe way)
DECLARE @Year INT = 2024;
DECLARE @TableName SYSNAME = N'OrdersArchive_' + CAST(@Year AS NVARCHAR(4));
DECLARE @sql NVARCHAR(MAX);
-- Validate that the table really exists (prevents injection through the name)
IF OBJECT_ID(N'dbo.' + QUOTENAME(@TableName), N'U') IS NULL
BEGIN
RAISERROR('Table %s does not exist.', 16, 1, @TableName);
END
ELSE
BEGIN
SET @sql = N'SELECT Status, COUNT(*) AS Orders FROM dbo.' + QUOTENAME(@TableName) + N' GROUP BY Status;';
PRINT @sql;
EXEC sys.sp_executesql @sql;
END;
GO
MessagesSELECT Status, COUNT(*) AS Orders FROM dbo.[OrdersArchive_2024] GROUP BY Status;
Output
| Status | Orders |
| Cancelled | 4 |
| Delivered | 50 |
Run the same query on Every archive table (loop over sys.tables)
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql = @sql + N'SELECT ''' + name + N''' AS SourceTable, COUNT(*) AS TotalOrders FROM dbo.'
+ QUOTENAME(name) + N' UNION ALL '
FROM sys.tables
WHERE name LIKE N'OrdersArchive[_]%';
SET @sql = LEFT(@sql, LEN(@sql) - LEN(N' UNION ALL')) + N';'; -- remove the last UNION ALL
PRINT @sql;
EXEC sys.sp_executesql @sql;
GO
MessagesSELECT 'OrdersArchive_2024' AS SourceTable, COUNT(*) AS TotalOrders FROM dbo.[OrdersArchive_2024] UNION ALL SELECT 'OrdersArchive_2025' AS SourceTable, COUNT(*) AS TotalOrders FROM dbo.[OrdersArchive_2025];
Output
| SourceTable | TotalOrders |
| OrdersArchive_2024 | 54 |
| OrdersArchive_2025 | 26 |
Part C: Dynamic Search Procedure (only add filters that were passed)
CREATE OR ALTER PROCEDURE dbo.usp_DynamicOrderSearch
@CustomerID INT = NULL,
@Status VARCHAR(20) = NULL,
@FromDate DATE = NULL,
@ToDate DATE = NULL,
@City VARCHAR(50) = NULL,
@SortColumn SYSNAME = N'OrderDate',
@SortDesc BIT = 1,
@Debug BIT = 0
AS
BEGIN
SET NOCOUNT ON;
-- Whitelist sort columns
IF @SortColumn NOT IN (N'OrderID', N'OrderDate', N'Status', N'ShipCity', N'CustomerID')
SET @SortColumn = N'OrderDate';
DECLARE @sql NVARCHAR(MAX) = N'
SELECT o.OrderID, o.OrderDate, o.Status, o.ShipCity, c.CustomerName
FROM dbo.Orders o
JOIN dbo.Customers c ON c.CustomerID = o.CustomerID
WHERE 1 = 1';
IF @CustomerID IS NOT NULL SET @sql += N' AND o.CustomerID = @CustomerID';
IF @Status IS NOT NULL SET @sql += N' AND o.Status = @Status';
IF @FromDate IS NOT NULL SET @sql += N' AND o.OrderDate >= @FromDate';
IF @ToDate IS NOT NULL SET @sql += N' AND o.OrderDate <= @ToDate';
IF @City IS NOT NULL SET @sql += N' AND o.ShipCity = @City';
SET @sql += N' ORDER BY o.' + QUOTENAME(@SortColumn) + CASE WHEN @SortDesc = 1 THEN N' DESC' ELSE N' ASC' END + N';';
IF @Debug = 1 PRINT @sql;
EXEC sys.sp_executesql @sql,
N'@CustomerID INT, @Status VARCHAR(20), @FromDate DATE, @ToDate DATE, @City VARCHAR(50)',
@CustomerID, @Status, @FromDate, @ToDate, @City;
END;
GO
EXEC dbo.usp_DynamicOrderSearch @Status = 'Pending', @Debug = 1;
EXEC dbo.usp_DynamicOrderSearch @City = 'Hyderabad', @FromDate = '2025-01-01', @SortColumn = 'OrderID', @SortDesc = 0, @Debug = 1;
EXEC dbo.usp_DynamicOrderSearch @CustomerID = 2;
GO
MessagesSELECT o.OrderID, o.OrderDate, o.Status, o.ShipCity, c.CustomerName
FROM dbo.Orders o
JOIN dbo.Customers c ON c.CustomerID = o.CustomerID
WHERE 1 = 1 AND o.Status = @Status ORDER BY o.[OrderDate] DESC;
Output
| OrderID | OrderDate | Status | ShipCity | CustomerName |
(0 rows)
MessagesSELECT o.OrderID, o.OrderDate, o.Status, o.ShipCity, c.CustomerName
FROM dbo.Orders o
JOIN dbo.Customers c ON c.CustomerID = o.CustomerID
WHERE 1 = 1 AND o.OrderDate >= @FromDate AND o.ShipCity = @City ORDER BY o.[OrderID] ASC;
Output
| OrderID | OrderDate | Status | ShipCity | CustomerName |
| 64 | 2025-03-08 | Delivered | Hyderabad | Ravi Traders |
| 71 | 2025-04-25 | Delivered | Hyderabad | Ravi Traders |
| 73 | 2025-05-09 | Delivered | Hyderabad | Ravi Traders |
| 75 | 2025-05-21 | Delivered | Hyderabad | Ravi Traders |
Output
| OrderID | OrderDate | Status | ShipCity | CustomerName |
| 58 | 2025-01-26 | Delivered | Bengaluru | Infosoft Solutions |
| 57 | 2025-01-18 | Delivered | Bengaluru | Infosoft Solutions |
| 35 | 2024-08-27 | Delivered | Bengaluru | Infosoft Solutions |
| 21 | 2024-05-22 | Cancelled | Bengaluru | Infosoft Solutions |
| 20 | 2024-05-17 | Delivered | Bengaluru | Infosoft Solutions |
| 14 | 2024-04-05 | Delivered | Bengaluru | Infosoft Solutions |
Part D: SQL Injection - why concatenating values is dangerous
-- Imagine @UserInput comes from a web page text box.
DECLARE @UserInput NVARCHAR(100) = N'Mumbai'' OR 1=1 --';
DECLARE @sql NVARCHAR(MAX);
-- BAD : value glued into the string -> attacker returns ALL customers
SET @sql = N'SELECT CustomerID, CustomerName, City FROM dbo.Customers WHERE City = ''' + @UserInput + N'''';
PRINT N'BAD : ' + @sql;
EXEC (@sql);
-- GOOD : value passed as a parameter -> treated as plain text, returns 0 rows
SET @sql = N'SELECT CustomerID, CustomerName, City FROM dbo.Customers WHERE City = @City';
PRINT N'GOOD : ' + @sql;
EXEC sys.sp_executesql @sql, N'@City NVARCHAR(100)', @City = @UserInput;
GO
MessagesBAD : SELECT CustomerID, CustomerName, City FROM dbo.Customers WHERE City = 'Mumbai' OR 1=1 --'
Output
| CustomerID | CustomerName | City |
| 1 | Ravi Traders | Hyderabad |
| 2 | Infosoft Solutions | Bengaluru |
| 3 | Lakshmi Stores | Chennai |
| 4 | Mumbai Mart | Mumbai |
| 5 | Pune Tech Park | Pune |
| 6 | Capital Electronics | Delhi |
| 7 | Bengal Book House | Kolkata |
| 8 | Deccan Systems | Hyderabad |
| 9 | Gujarat Office Supply | Ahmedabad |
| 10 | Pink City Furnishers | Jaipur |
| 11 | Kerala Kart | Kochi |
| 12 | Awadh Enterprises | Lucknow |
| 13 | Silicon Valley Pvt Ltd | Bengaluru |
| 14 | Gateway Retail | Mumbai |
| 15 | Marina Digital | Chennai |
... showing 15 of 20 rows
MessagesGOOD : SELECT CustomerID, CustomerName, City FROM dbo.Customers WHERE City = @City
Output
| CustomerID | CustomerName | City |
(0 rows)
Part E: Dynamic Column List / Metadata Driven Queries
Count NULLs in every column of a table (data quality check)
DECLARE @Table SYSNAME = N'Customers';
DECLARE @sql NVARCHAR(MAX);
SELECT @sql = STRING_AGG(
CAST(N'SELECT ''' + c.name + N''' AS ColumnName, COUNT(*) - COUNT(' + QUOTENAME(c.name)
+ N') AS NullCount FROM dbo.' + QUOTENAME(@Table) AS NVARCHAR(MAX)),
N' UNION ALL ')
FROM sys.columns c
WHERE c.object_id = OBJECT_ID(N'dbo.' + QUOTENAME(@Table));
PRINT @sql;
EXEC sys.sp_executesql @sql;
GO
MessagesSELECT 'CustomerID' AS ColumnName, COUNT(*) - COUNT([CustomerID]) AS NullCount FROM dbo.[Customers] UNION ALL SELECT 'CustomerName' AS ColumnName, COUNT(*) - COUNT([CustomerName]) AS NullCount FROM dbo.[Customers] UNION ALL SELECT 'Email' AS ColumnName, COUNT(*) - COUNT([Email]) AS NullCount FROM dbo.[Customers] UNION ALL SELECT 'Phone' AS ColumnName, COUNT(*) - COUNT([Phone]) AS NullCount FROM dbo.[Customers] UNION ALL SELECT 'City' AS ColumnName, COUNT(*) - COUNT([City]) AS NullCount FROM dbo.[Customers] UNION ALL SELECT 'State' AS ColumnName, COUNT(*) - COUNT([State]) AS NullCount FROM dbo.[Customers] UNION ALL SELECT 'Segment' AS ColumnName, COUNT(*) - COUNT([Segment]) AS NullCount FROM dbo.[Customers] UNION ALL SELECT 'SignupDate' AS ColumnName, COUNT(*) - COUNT([SignupDate]) AS NullCount FROM dbo.[Customers]
Output
| ColumnName | NullCount |
| CustomerID | 0 |
| CustomerName | 0 |
| Email | 2 |
| Phone | 2 |
| City | 0 |
| State | 0 |
| Segment | 0 |
| SignupDate | 0 |
Row count for every user table (no dynamic SQL needed - use catalog views)
SELECT t.name AS TableName, SUM(p.rows) AS TotalRows
FROM sys.tables t
JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
GROUP BY t.name
ORDER BY TotalRows DESC;
GO
Output
| TableName | TotalRows |
| LockDemo | 20000 |
| FactSalesRow | 1200 |
| FactSalesCCI | 1194 |
| FiltOrders | 300 |
| IdxOrders | 300 |
| BigOrdersData | 300 |
| CompOrders | 300 |
| OrdersByDate | 300 |
| OrdersClustered | 300 |
| OrdersHeap | 300 |
| OrderItems | 202 |
| IntKeyDemo | 200 |
| GuidKeyDemo | 200 |
| Sales | 178 |
| Orders | 80 |
... showing 15 of 69 rows
Part F: Scope Rules
- Dynamic SQL runs in its OWN batch/scope:
- cannot see local @variables of the caller (pass them as parameters)
- #temp tables created by the caller ARE visible inside dynamic SQL
- #temp tables created INSIDE dynamic SQL are NOT visible after it ends
DROP TABLE IF EXISTS #Outer;
CREATE TABLE #Outer (ID INT);
INSERT INTO #Outer VALUES (1), (2);
EXEC (N'SELECT COUNT(*) AS SeenFromDynamicSql FROM #Outer;'); -- works
EXEC (N'CREATE TABLE #Inner (ID INT); INSERT INTO #Inner VALUES (1);');
-- SELECT * FROM #Inner; -- would fail: #Inner is already gone
DROP TABLE #Outer;
GO
Messages(2 rows affected)
Practice Questions
- Write a procedure that returns TOP N rows from any table name passed in (validate the name!).
- Build a dynamic query that sums Sales.TotalAmount grouped by a column chosen at run time
- Generate and run "SELECT MAX(<col>)" for every DATE column in the database.
- Write a dynamic PIVOT of Sales: rows = Region, columns = each distinct ProductID.