| name | sqlserver-patterns |
| description | T-SQL and SQL Server best practices, query optimization, stored procedures, indexing strategies, security, and administration patterns for SQL Server 2016+ and Azure SQL Database. |
SQL Server / T-SQL Patterns
Quick reference for SQL Server and T-SQL best practices. For detailed guidance, use the database-reviewer agent.
When to Activate
- Writing T-SQL queries, stored procedures, or functions
- Designing SQL Server schemas or migrations
- Troubleshooting slow queries or high I/O
- Implementing error handling or transaction management
- Reviewing indexing strategy or query plans
- Working with Azure SQL Database
- Migrating from or to PostgreSQL
Core Principles
1. Parameterized Queries and sp_executesql
Never concatenate user input into SQL strings. Always use parameters.
CREATE PROCEDURE dbo.usp_GetUserById
@UserId INT
AS
BEGIN
SET NOCOUNT ON;
SELECT
UserId,
Email,
DisplayName,
CreatedAt
FROM dbo.Users
WHERE UserId = @UserId
AND IsActive = 1;
END;
GO
DECLARE @TableName SYSNAME = N'dbo.Orders';
DECLARE @StatusFilter NVARCHAR(50) = N'Shipped';
DECLARE @sql NVARCHAR(MAX);
SET @sql = N'
SELECT OrderId, CustomerId, TotalAmount
FROM ' + QUOTENAME(@TableName) + N'
WHERE Status = @Status';
EXEC sp_executesql
@sql,
N'@Status NVARCHAR(50)',
@Status = @StatusFilter;
DECLARE @badSql NVARCHAR(MAX) =
'SELECT * FROM Users WHERE Name = ''' + @UserInput + '''';
EXEC (@badSql);
Rules for dynamic SQL:
- Use
QUOTENAME() for identifiers (table/column names)
- Always pass values as parameters to
sp_executesql, never embed them
- Validate identifier inputs against a whitelist when possible
2. SET-Based Operations over Cursors
SQL Server is optimized for set operations. Row-by-row cursors are rarely necessary.
DECLARE @OrderId INT, @Total DECIMAL(10,2);
DECLARE cur CURSOR FOR SELECT OrderId, TotalAmount FROM dbo.Orders;
OPEN cur;
FETCH NEXT FROM cur INTO @OrderId, @Total;
WHILE @@FETCH_STATUS = 0
BEGIN
IF @Total > 1000
UPDATE dbo.Orders SET Discount = 0.10 WHERE OrderId = @OrderId;
FETCH NEXT FROM cur INTO @OrderId, @Total;
END;
CLOSE cur; DEALLOCATE cur;
UPDATE dbo.Orders
SET Discount = 0.10
WHERE TotalAmount > 1000;
DECLARE @BatchSize INT = 1000;
DECLARE @Rows INT = 1;
WHILE @Rows > 0
BEGIN
UPDATE TOP (@BatchSize) dbo.Orders
SET ArchivedAt = SYSUTCDATETIME()
WHERE CreatedAt < DATEADD(YEAR, -5, SYSUTCDATETIME())
AND ArchivedAt IS NULL;
SET @Rows = @@ROWCOUNT;
WAITFOR DELAY '00:00:01';
END;
3. Indexing Strategy
Clustered vs Non-Clustered
CREATE TABLE dbo.Orders (
OrderId INT IDENTITY(1,1) NOT NULL,
CustomerId INT NOT NULL,
CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
Status NVARCHAR(20) NOT NULL DEFAULT N'Pending',
Total DECIMAL(12, 2) NOT NULL,
CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderId)
);
CREATE NONCLUSTERED INDEX IX_Orders_CustomerId
ON dbo.Orders (CustomerId)
INCLUDE (CreatedAt, Status, Total);
CREATE NONCLUSTERED INDEX IX_Orders_Status_CreatedAt
ON dbo.Orders (Status, CreatedAt DESC);
CREATE NONCLUSTERED INDEX IX_Orders_Pending
ON dbo.Orders (CreatedAt)
WHERE Status = N'Pending';
CREATE UNIQUE NONCLUSTERED INDEX UIX_Users_Email
ON dbo.Users (Email)
WHERE IsActive = 1;
Index Maintenance
SELECT
OBJECT_NAME(ips.object_id) AS TableName,
i.name AS IndexName,
ips.avg_fragmentation_in_percent,
ips.page_count
FROM sys.dm_db_index_physical_stats(
DB_ID(), NULL, NULL, NULL, 'LIMITED') AS ips
JOIN sys.indexes AS i
ON i.object_id = ips.object_id
AND i.index_id = ips.index_id
WHERE ips.avg_fragmentation_in_percent > 5
AND ips.page_count > 100
ORDER BY ips.avg_fragmentation_in_percent DESC;
ALTER INDEX IX_Orders_CustomerId ON dbo.Orders REBUILD WITH (ONLINE = ON);
ALTER INDEX IX_Orders_CustomerId ON dbo.Orders REORGANIZE;
UPDATE STATISTICS dbo.Orders WITH FULLSCAN;
Missing Index Hints from Query Plans
SELECT
dm_mid.statement AS TableName,
dm_migs.avg_total_user_cost * dm_migs.avg_user_impact
* (dm_migs.user_seeks + dm_migs.user_scans) AS ImprovementMeasure,
'CREATE INDEX IX_' +
REPLACE(dm_mid.statement, '.', '_') + '_' +
REPLACE(REPLACE(ISNULL(dm_mid.equality_columns, ''), '[', ''), ']', '')
AS SuggestedIndexName,
dm_mid.equality_columns,
dm_mid.inequality_columns,
dm_mid.included_columns
FROM sys.dm_db_missing_index_group_stats AS dm_migs
JOIN sys.dm_db_missing_index_groups AS dm_mig
ON dm_migs.group_handle = dm_mig.index_group_handle
JOIN sys.dm_db_missing_index_details AS dm_mid
ON dm_mig.index_handle = dm_mid.index_handle
WHERE dm_mid.database_id = DB_ID()
ORDER BY ImprovementMeasure DESC;
4. Transaction Handling and Isolation Levels
Explicit Transactions with Error Handling
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO dbo.Orders (CustomerId, Total, Status)
VALUES (@CustomerId, @Total, N'Pending');
DECLARE @OrderId INT = SCOPE_IDENTITY();
INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)
SELECT @OrderId, ProductId, Quantity, UnitPrice
FROM @OrderItemsTable;
UPDATE dbo.Inventory
SET StockQuantity -= oi.Quantity
FROM dbo.Inventory AS inv
JOIN @OrderItemsTable AS oi ON inv.ProductId = oi.ProductId;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
DECLARE @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
DECLARE @ErrorSeverity INT = ERROR_SEVERITY();
DECLARE @ErrorState INT = ERROR_STATE();
RAISERROR(@ErrorMessage, @ErrorSeverity, @ErrorState);
END CATCH;
Isolation Levels
ALTER DATABASE MyDb SET READ_COMMITTED_SNAPSHOT ON;
ALTER DATABASE MyDb SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
SELECT CustomerId, SUM(Total) AS TotalSpend
FROM dbo.Orders
WHERE CreatedAt >= '2024-01-01'
GROUP BY CustomerId;
COMMIT;
SELECT COUNT(*) FROM dbo.EventLog WITH (NOLOCK);
BEGIN TRANSACTION;
SELECT @Existing = UserId
FROM dbo.Users WITH (UPDLOCK, HOLDLOCK)
WHERE Email = @Email;
IF @Existing IS NULL
INSERT INTO dbo.Users (Email) VALUES (@Email);
COMMIT;
5. Stored Procedures and Functions
Stored Procedure Template
CREATE OR ALTER PROCEDURE dbo.usp_CreateOrder
@CustomerId INT,
@OrderItems dbo.OrderItemType READONLY,
@CreatedOrderId INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @CustomerId IS NULL OR @CustomerId <= 0
THROW 50001, 'Invalid CustomerId.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.Customers WHERE CustomerId = @CustomerId AND IsActive = 1)
THROW 50002, 'Customer not found or inactive.', 1;
IF NOT EXISTS (SELECT 1 FROM @OrderItems)
THROW 50003, 'Order must contain at least one item.', 1;
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO dbo.Orders (CustomerId, Status, CreatedAt)
VALUES (@CustomerId, N'Pending', SYSUTCDATETIME());
SET @CreatedOrderId = SCOPE_IDENTITY();
INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)
SELECT @CreatedOrderId, ProductId, Quantity, UnitPrice
FROM @OrderItems;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO
CREATE TYPE dbo.OrderItemType AS TABLE (
ProductId INT NOT NULL,
Quantity INT NOT NULL,
UnitPrice DECIMAL(10, 2) NOT NULL
);
GO
Inline TVF vs Multi-Statement TVF
CREATE OR ALTER FUNCTION dbo.ufn_GetOrdersByCustomer
(@CustomerId INT)
RETURNS TABLE
AS
RETURN (
SELECT
o.OrderId,
o.CreatedAt,
o.Total,
o.Status
FROM dbo.Orders AS o
WHERE o.CustomerId = @CustomerId
);
GO
SELECT u.Email, o.OrderId, o.Total
FROM dbo.Users AS u
CROSS APPLY dbo.ufn_GetOrdersByCustomer(u.UserId) AS o
WHERE o.Total > 500;
CREATE FUNCTION dbo.ufn_ComplexCalc(@Param INT)
RETURNS @Result TABLE (Col1 INT, Col2 NVARCHAR(100))
AS
BEGIN
INSERT INTO @Result ...
RETURN;
END;
6. CTEs and Window Functions
WITH CustomerSpend AS (
SELECT
CustomerId,
SUM(Total) AS TotalSpend,
COUNT(*) AS OrderCount,
MAX(CreatedAt) AS LastOrderDate
FROM dbo.Orders
WHERE Status = N'Completed'
GROUP BY CustomerId
),
RankedCustomers AS (
SELECT
cs.*,
ROW_NUMBER() OVER (ORDER BY TotalSpend DESC) AS SpendRank
FROM CustomerSpend AS cs
)
SELECT
u.Email,
rc.TotalSpend,
rc.OrderCount,
rc.SpendRank
FROM RankedCustomers AS rc
JOIN dbo.Users AS u ON rc.CustomerId = u.UserId
WHERE rc.SpendRank <= 100;
SELECT
OrderId,
CustomerId,
CreatedAt,
Total,
SUM(Total) OVER (PARTITION BY CustomerId ORDER BY CreatedAt
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
AS RunningTotal,
LAG(Total, 1, 0) OVER (PARTITION BY CustomerId ORDER BY CreatedAt)
AS PreviousOrderTotal,
LEAD(Total, 1) OVER (PARTITION BY CustomerId ORDER BY CreatedAt)
AS NextOrderTotal,
RANK() OVER (PARTITION BY CustomerId ORDER BY Total DESC)
AS RankByValue
FROM dbo.Orders
WHERE Status = N'Completed';
WITH OrgTree AS (
SELECT EmployeeId, ManagerId, Name, 0 AS Level
FROM dbo.Employees
WHERE ManagerId IS NULL
UNION ALL
SELECT e.EmployeeId, e.ManagerId, e.Name, ot.Level + 1
FROM dbo.Employees AS e
JOIN OrgTree AS ot ON e.ManagerId = ot.EmployeeId
)
SELECT EmployeeId, Name, Level
FROM OrgTree
ORDER BY Level, Name
OPTION (MAXRECURSION 50);
SELECT *
FROM (
SELECT CustomerId, YEAR(CreatedAt) AS OrderYear, Total
FROM dbo.Orders
) AS src
PIVOT (
SUM(Total) FOR OrderYear IN ([2022], [2023], [2024])
) AS pvt;
7. Error Handling with TRY/CATCH and THROW
BEGIN TRY
EXEC dbo.usp_CreateOrder @CustomerId = @CustomerId, @OrderItems = @Items;
END TRY
BEGIN CATCH
INSERT INTO dbo.ErrorLog (
ErrorNumber, ErrorSeverity, ErrorState,
ErrorProcedure, ErrorLine, ErrorMessage, LoggedAt
)
VALUES (
ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(),
ERROR_PROCEDURE(), ERROR_LINE(), ERROR_MESSAGE(),
SYSUTCDATETIME()
);
THROW;
END CATCH;
IF @Quantity <= 0
THROW 50010, 'Quantity must be greater than zero.', 1;
RAISERROR('Legacy error: %s', 16, 1, @Message);
CREATE PROCEDURE dbo.usp_Inner
AS
BEGIN
SET NOCOUNT ON;
SAVE TRANSACTION SaveInner;
BEGIN TRY
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION SaveInner;
THROW;
END CATCH;
END;
8. Temporal Tables and Row Versioning
CREATE TABLE dbo.Products (
ProductId INT IDENTITY(1,1) NOT NULL,
Name NVARCHAR(200) NOT NULL,
Price DECIMAL(10, 2) NOT NULL,
IsActive BIT NOT NULL DEFAULT 1,
ValidFrom DATETIME2(7) GENERATED ALWAYS AS ROW START NOT NULL,
ValidTo DATETIME2(7) GENERATED ALWAYS AS ROW END NOT NULL,
CONSTRAINT PK_Products PRIMARY KEY (ProductId),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.ProductsHistory));
SELECT ProductId, Name, Price FROM dbo.Products WHERE IsActive = 1;
SELECT ProductId, Name, Price
FROM dbo.Products
FOR SYSTEM_TIME AS OF '2024-06-01T00:00:00';
SELECT ProductId, Name, Price, ValidFrom, ValidTo
FROM dbo.Products
FOR SYSTEM_TIME ALL
WHERE ProductId = 42
ORDER BY ValidFrom;
SELECT ProductId, Name, Price, ValidFrom, ValidTo
FROM dbo.Products
FOR SYSTEM_TIME BETWEEN '2024-01-01' AND '2024-12-31'
WHERE ProductId = 42;
ALTER TABLE dbo.Products SET (SYSTEM_VERSIONING = OFF);
ALTER TABLE dbo.Products SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.ProductsHistory));
9. Query Plan Analysis
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT u.Email, COUNT(o.OrderId) AS OrderCount
FROM dbo.Users AS u
JOIN dbo.Orders AS o ON u.UserId = o.UserId
GROUP BY u.Email;
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
SET STATISTICS PROFILE ON;
SET STATISTICS PROFILE OFF;
SELECT TOP 20
qs.total_logical_reads / qs.execution_count AS AvgLogicalReads,
qs.total_elapsed_time / qs.execution_count AS AvgElapsedTime_us,
qs.execution_count,
SUBSTRING(qt.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(qt.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset)/2)+1) AS QueryText,
qp.query_plan
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS qt
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
ORDER BY AvgLogicalReads DESC;
SELECT *
FROM dbo.Orders
WHERE CustomerId = 1
OPTION (RECOMPILE);
SELECT *
FROM dbo.Orders AS o
JOIN dbo.Users AS u ON o.UserId = u.UserId
OPTION (HASH JOIN, MAXDOP 4);
10. Azure SQL Database vs On-Premises Differences
| Feature | SQL Server (On-Prem) | Azure SQL Database |
|---|
| Instance scope | Full SQL Server instance | Single database (Hyperscale: multi-node) |
| Authentication | Windows Auth + SQL Auth | SQL Auth, Azure AD, Managed Identity |
| High availability | Always On AG, FCI | Built-in (SLA 99.99%) |
| Backup | Manual / SQL Agent | Automatic, point-in-time restore |
| Scale | Manual hardware | DTU / vCore, serverless, auto-scale |
| Linked servers | Supported | Not supported (use Elastic Query / Fabric) |
| SQL Agent | Full | Limited (Elastic Jobs instead) |
xp_cmdshell | Optional (disabled by default) | Not available |
BULK INSERT | Local and network paths | Azure Blob Storage only |
tempdb | Shared instance tempdb | Per-database (Hyperscale: remote) |
CREATE USER [myapp@mytenant.onmicrosoft.com] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [myapp@mytenant.onmicrosoft.com];
CREATE EXTERNAL DATA SOURCE RemoteDB
WITH (
TYPE = RDBMS,
LOCATION = N'myserver.database.windows.net',
DATABASE_NAME = N'OtherDatabase',
CREDENTIAL = RemoteCredential
);
CREATE EXTERNAL TABLE dbo.RemoteOrders (
OrderId INT,
CustomerId INT,
Total DECIMAL(12,2)
)
WITH (DATA_SOURCE = RemoteDB, SCHEMA_NAME = N'dbo', OBJECT_NAME = N'Orders');
11. Schema Design
CREATE TABLE dbo.Users (
UserId INT IDENTITY(1,1) NOT NULL,
Email NVARCHAR(254) NOT NULL,
DisplayName NVARCHAR(100) NOT NULL,
IsActive BIT NOT NULL CONSTRAINT DF_Users_IsActive DEFAULT 1,
CreatedAt DATETIME2(7) NOT NULL CONSTRAINT DF_Users_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(7) NOT NULL CONSTRAINT DF_Users_UpdatedAt DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_Users PRIMARY KEY CLUSTERED (UserId),
CONSTRAINT UIX_Users_Email UNIQUE NONCLUSTERED (Email),
CONSTRAINT CK_Users_Email CHECK (Email LIKE '%@%.%')
);
CREATE TABLE dbo.Orders (
OrderId INT IDENTITY(1,1) NOT NULL,
UserId INT NOT NULL,
Status NVARCHAR(20) NOT NULL
CONSTRAINT DF_Orders_Status DEFAULT N'Pending',
Total DECIMAL(12, 2) NOT NULL,
CreatedAt DATETIME2(7) NOT NULL CONSTRAINT DF_Orders_CreatedAt DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderId),
CONSTRAINT FK_Orders_Users FOREIGN KEY (UserId) REFERENCES dbo.Users (UserId),
CONSTRAINT CK_Orders_Status CHECK (Status IN (N'Pending', N'Processing', N'Shipped', N'Completed', N'Cancelled')),
CONSTRAINT CK_Orders_Total CHECK (Total >= 0)
);
ALTER TABLE dbo.Orders ADD ExternalId UNIQUEIDENTIFIER NOT NULL DEFAULT NEWSEQUENTIALID();
CREATE UNIQUE INDEX UIX_Orders_ExternalId ON dbo.Orders (ExternalId);
12. T-SQL vs ANSI SQL — Portability Considerations
SELECT TOP 10 * FROM dbo.Orders ORDER BY CreatedAt DESC;
SELECT * FROM dbo.Orders ORDER BY CreatedAt DESC
OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;
SELECT ISNULL(MiddleName, N'') AS MiddleName FROM dbo.Users;
SELECT COALESCE(MiddleName, N'') AS MiddleName FROM dbo.Users;
SELECT IIF(Total > 1000, N'High', N'Normal') AS OrderTier FROM dbo.Orders;
SELECT CASE WHEN Total > 1000 THEN N'High' ELSE N'Normal' END AS OrderTier FROM dbo.Orders;
SELECT LEN(Email) FROM dbo.Users;
SELECT DATALENGTH(Email) FROM dbo.Users;
SELECT GETDATE();
SELECT SYSUTCDATETIME();
SELECT SYSDATETIMEOFFSET();
SELECT DATEADD(DAY, 30, SYSUTCDATETIME());
SELECT DATEDIFF(DAY, CreatedAt, SYSUTCDATETIME()) AS AgeDays FROM dbo.Orders;
SELECT UserId, Email
FROM dbo.Users
FOR JSON PATH, ROOT('users');
SELECT
CustomerId,
STRING_AGG(CAST(OrderId AS NVARCHAR), N', ')
WITHIN GROUP (ORDER BY CreatedAt) AS OrderIds
FROM dbo.Orders
GROUP BY CustomerId;
Testing with tSQLt
EXEC tSQLt.RunAll;
EXEC tSQLt.Run 'UserTests';
EXEC tSQLt.NewTestClass 'UserTests';
GO
CREATE PROCEDURE UserTests.[test usp_GetUserById returns correct user]
AS
BEGIN
EXEC tSQLt.FakeTable 'dbo', 'Users';
INSERT INTO dbo.Users (UserId, Email, DisplayName, IsActive)
VALUES (1, 'alice@example.com', 'Alice', 1),
(2, 'bob@example.com', 'Bob', 1);
SELECT * INTO #ActualResult FROM dbo.Users WHERE 1 = 0;
INSERT INTO #ActualResult
EXEC dbo.usp_GetUserById @UserId = 1;
SELECT 1 AS UserId, 'alice@example.com' AS Email, 'Alice' AS DisplayName, 1 AS IsActive
INTO #ExpectedResult;
EXEC tSQLt.AssertEqualsTable '#ExpectedResult', '#ActualResult';
END;
GO
CREATE PROCEDURE UserTests.[test usp_GetUserById throws on null UserId]
AS
BEGIN
EXEC tSQLt.FakeTable 'dbo', 'Users';
EXEC tSQLt.ExpectException
@ExpectedErrorNumber = 50001,
@ExpectedMessagePattern = '%Invalid%';
EXEC dbo.usp_GetUserById @UserId = NULL;
END;
GO
CREATE PROCEDURE UserTests.[test usp_CreateUser inserts one row]
AS
BEGIN
EXEC tSQLt.FakeTable 'dbo', 'Users';
EXEC dbo.usp_CreateUser
@Email = 'new@example.com',
@DisplayName = 'New User';
EXEC tSQLt.AssertEquals 1, (SELECT COUNT(*) FROM dbo.Users);
END;
GO
Anti-Patterns
| Anti-Pattern | Why It Hurts | Preferred Alternative |
|---|
CURSOR for set operations | Row-by-row; orders of magnitude slower | Single UPDATE/INSERT/DELETE |
SELECT * | Breaks on schema change; over-fetches | List columns explicitly |
Implicit type conversion (WHERE IntCol = '42') | Index scan instead of seek; silent data loss | Match parameter type to column type |
Non-sargable predicates (WHERE YEAR(CreatedAt) = 2024) | Cannot use index | WHERE CreatedAt >= '2024-01-01' AND CreatedAt < '2025-01-01' |
| Dynamic SQL string concatenation | SQL injection; hard to cache plans | sp_executesql with parameters |
NOLOCK everywhere | Dirty reads; phantom reads; data inconsistency | READ_COMMITTED_SNAPSHOT at database level |
FLOAT/REAL for money | Floating-point rounding errors | DECIMAL(p, s) or MONEY (with caveats) |
Missing WHERE on UPDATE/DELETE | Updates/deletes all rows | Always include predicate; review before running |
| Wide clustered index key (GUID) | Page splits; larger non-clustered indexes | INT IDENTITY or NEWSEQUENTIALID() |
| Multi-statement TVF | Optimizer treats as black box (1 row estimate) | Inline TVF or stored procedure |
Implicit transactions off (SET IMPLICIT_TRANSACTIONS ON) | Locks held until explicit COMMIT | Use SET IMPLICIT_TRANSACTIONS OFF (default) |
GETDATE() for UTC timestamps | Returns server local time | SYSUTCDATETIME() for UTC |
| Storing comma-separated values in a column | Cannot index, join, or filter efficiently | Normalize or use JSON/XML column |
Quick Reference
Common Patterns
Upsert (MERGE):
MERGE dbo.Settings AS target
USING (VALUES (@UserId, @Key, @Value)) AS src (UserId, [Key], Value)
ON target.UserId = src.UserId AND target.[Key] = src.[Key]
WHEN MATCHED THEN UPDATE SET target.Value = src.Value, target.UpdatedAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN INSERT (UserId, [Key], Value) VALUES (src.UserId, src.[Key], src.Value);
Keyset Pagination (efficient for large tables):
SELECT TOP (@PageSize) OrderId, CreatedAt, Total
FROM dbo.Orders
WHERE CreatedAt < @LastSeenCreatedAt
OR (CreatedAt = @LastSeenCreatedAt AND OrderId < @LastSeenOrderId)
ORDER BY CreatedAt DESC, OrderId DESC;
OFFSET/FETCH Pagination (simple cases):
SELECT OrderId, CreatedAt, Total
FROM dbo.Orders
ORDER BY CreatedAt DESC
OFFSET (@Page - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;
Batch Delete (avoid long locks):
DECLARE @Rows INT = 1;
WHILE @Rows > 0
BEGIN
DELETE TOP (5000) FROM dbo.EventLog
WHERE CreatedAt < DATEADD(DAY, -90, SYSUTCDATETIME());
SET @Rows = @@ROWCOUNT;
END;
EXISTS vs COUNT for row check:
IF EXISTS (SELECT 1 FROM dbo.Users WHERE Email = @Email)
PRINT 'Found';
IF (SELECT COUNT(*) FROM dbo.Users WHERE Email = @Email) > 0
PRINT 'Found';
Safe column rename migration:
ALTER TABLE dbo.Users ADD EmailAddress NVARCHAR(254) NULL;
UPDATE dbo.Users SET EmailAddress = Email;
ALTER TABLE dbo.Users ALTER COLUMN EmailAddress NVARCHAR(254) NOT NULL;
ALTER TABLE dbo.Users DROP COLUMN Email;
JSON support (SQL Server 2016+):
CREATE TABLE dbo.Events (
EventId INT IDENTITY PRIMARY KEY,
Payload NVARCHAR(MAX) NOT NULL CHECK (ISJSON(Payload) = 1),
CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME()
);
SELECT
EventId,
JSON_VALUE(Payload, '$.userId') AS UserId,
JSON_VALUE(Payload, '$.action') AS Action,
JSON_QUERY(Payload, '$.metadata') AS Metadata
FROM dbo.Events
WHERE JSON_VALUE(Payload, '$.action') = N'Login';
SELECT UserId, Email, DisplayName
FROM dbo.Users
WHERE IsActive = 1
FOR JSON PATH, ROOT('users');
Index Cheat Sheet
| Query Pattern | Index Strategy |
|---|
WHERE Col = @val | Non-clustered on Col |
WHERE Col1 = @a AND Col2 > @b | Composite (Col1, Col2) — equality first |
SELECT a, b FROM t WHERE c = @x | Index on c INCLUDE (a, b) |
WHERE Status = 'Pending' (sparse) | Filtered index WHERE Status = 'Pending' |
ORDER BY CreatedAt DESC on large table | Include CreatedAt in index |
| Full-text search | CREATE FULLTEXT INDEX |
Data Type Quick Reference
| Use Case | Preferred Type | Avoid |
|---|
| Surrogate PK | INT IDENTITY | UNIQUEIDENTIFIER (clustered) |
| Distributed key | UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID() | NEWID() (random, causes splits) |
| Timestamp / audit | DATETIME2(7) | DATETIME, SMALLDATETIME |
| Money / finance | DECIMAL(19, 4) | FLOAT, REAL |
| Short string | NVARCHAR(n) | VARCHAR(n) (unless ASCII-only confirmed) |
| Large text | NVARCHAR(MAX) | TEXT, NTEXT (deprecated) |
| Boolean | BIT | INT, CHAR(1) |
| Binary data | VARBINARY(MAX) | IMAGE (deprecated) |
Related
- Agent:
database-reviewer - Full database review workflow
- Skill:
postgres-patterns - PostgreSQL patterns and idioms
- Skill:
backend-patterns - API and backend integration patterns
- Rules:
sql/coding-style - SQL formatting and naming conventions
- Rules:
sql/security - SQL injection prevention and permissions