| name | sqlserver-query-optimization |
| description | Fix SQL Server stored procedures, views, and T-SQL anti-patterns. Covers non-SARGable predicates, implicit conversions, cursor replacement, DELETE+INSERT anti-pattern, CTE vs temp table decisions, and query hints. |
SQL Server Query Optimization
Use this skill when you have a specific stored procedure, view, or T-SQL batch that needs to be rewritten or improved. This is about fixing the code — for hardware/index problems, start with sqlserver-diagnostics and sqlserver-indexes.
Stored Procedure Anti-Patterns
1. DELETE + INSERT Per Request (Most Common)
Problem: A stored procedure deletes and re-inserts the same row every time it runs, even when nothing changed.
CREATE PROCEDURE usp_UpsertUserPreference
@UserID INT, @Key NVARCHAR(50), @Value NVARCHAR(500)
AS
DELETE FROM UserPreferences WHERE UserID = @UserID AND PreferenceKey = @Key;
INSERT INTO UserPreferences (UserID, PreferenceKey, PreferenceValue)
VALUES (@UserID, @Key, @Value);
GO
Problems:
- Two DML statements = two log writes, two index operations
- Triggers fire on DELETE even when nothing really changed
- Breaks foreign keys that point to this row
- Causes unnecessary index fragmentation
Fix — MERGE:
CREATE PROCEDURE usp_UpsertUserPreference
@UserID INT, @Key NVARCHAR(50), @Value NVARCHAR(500)
AS
MERGE UserPreferences AS target
USING (VALUES (@UserID, @Key, @Value)) AS source (UserID, PreferenceKey, PreferenceValue)
ON target.UserID = source.UserID AND target.PreferenceKey = source.PreferenceKey
WHEN MATCHED AND target.PreferenceValue != source.PreferenceValue THEN
UPDATE SET target.PreferenceValue = source.PreferenceValue,
target.UpdatedAt = GETDATE()
WHEN NOT MATCHED THEN
INSERT (UserID, PreferenceKey, PreferenceValue, CreatedAt)
VALUES (source.UserID, source.PreferenceKey, source.PreferenceValue, GETDATE());
GO
Alternative — staleness check:
IF EXISTS (SELECT 1 FROM UserPreferences WHERE UserID = @UserID AND PreferenceKey = @Key)
BEGIN
UPDATE UserPreferences
SET PreferenceValue = @Value, UpdatedAt = GETDATE()
WHERE UserID = @UserID AND PreferenceKey = @Key
AND PreferenceValue != @Value;
END
ELSE
BEGIN
INSERT INTO UserPreferences (UserID, PreferenceKey, PreferenceValue, CreatedAt)
VALUES (@UserID, @Key, @Value, GETDATE());
END
2. Cursor Loops (Replace with Set-Based Operations)
DECLARE @OrderID INT;
DECLARE cur CURSOR FOR SELECT OrderID FROM Orders WHERE Status = 'Pending';
OPEN cur;
FETCH NEXT FROM cur INTO @OrderID;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC usp_ProcessOrder @OrderID;
FETCH NEXT FROM cur INTO @OrderID;
END
CLOSE cur; DEALLOCATE cur;
Fix — set-based:
UPDATE Orders
SET ProcessedAt = GETDATE(), Status = 'Processing'
WHERE Status = 'Pending';
If usp_ProcessOrder has complex logic that can't be inlined, consider:
- Rewriting the proc to accept a table-valued parameter (TVP)
- Using a temp table as a queue and processing via a single bulk statement
- Using
STRING_AGG / FOR XML PATH for string concatenation (not cursors)
3. Non-SARGable Predicates
A SARGable predicate (Search ARGument able) allows SQL Server to use an index seek. Non-SARGable predicates force scans.
WHERE YEAR(OrderDate) = 2024
WHERE CONVERT(VARCHAR, OrderDate, 101) = '01/15/2024'
WHERE LEN(CustomerName) > 10
WHERE OrderTotal + 100 > 5000
WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01'
WHERE OrderDate = '2024-01-15'
WHERE CustomerName LIKE 'A%'
WHERE OrderTotal > 4900
WHERE CustomerName LIKE '%Smith%'
4. Implicit Conversions
When a column and a parameter have different data types, SQL Server converts the entire column — forcing an index scan.
WHERE CustomerCode = 'ACME123'
WHERE CustomerCode = N'ACME123'
WHERE Status = '1'
WHERE Status = 1
Detect implicit conversions in execution plans:
- Look for a
CONVERT_IMPLICIT function in the predicate tooltip
- Or query:
sys.dm_exec_query_stats + sys.dm_exec_sql_text and filter on CONVERT_IMPLICIT
5. SELECT * in Production Code
SELECT * FROM Orders JOIN Customers ON Orders.CustomerID = Customers.CustomerID;
SELECT
o.OrderID, o.OrderDate, o.TotalAmount, o.Status,
c.CompanyName, c.ContactEmail
FROM Orders o
JOIN Customers c ON o.CustomerID = c.CustomerID;
Why it matters:
- Breaks if a column is added, renamed, or reordered in the base table
- Fetches columns the application doesn't need (extra I/O, network)
- Prevents SQL Server from using covering index optimization
View Optimization
When Views Expand Inline
By default, SQL Server expands a view definition inline during query compilation. The optimizer sees the full base table query — this is usually fine.
CREATE VIEW vw_ActiveOrders AS
SELECT o.*, c.CompanyName
FROM Orders o JOIN Customers c ON o.CustomerID = c.CustomerID
WHERE o.Status = 'Active';
Indexed Views (Materialized)
An indexed view stores the result set physically. Dramatically speeds up aggregation queries.
CREATE VIEW vw_SalesByRegion
WITH SCHEMABINDING AS
SELECT RegionID, COUNT_BIG(*) AS OrderCount, SUM(TotalAmount) AS TotalSales
FROM dbo.Orders
GROUP BY RegionID;
GO
CREATE UNIQUE CLUSTERED INDEX IX_vw_SalesByRegion ON vw_SalesByRegion (RegionID);
On Enterprise Edition, SQL Server automatically uses the indexed view even when the query references the base table. On Standard Edition, use WITH (NOEXPAND) hint:
SELECT * FROM vw_SalesByRegion WITH (NOEXPAND) WHERE RegionID = 5;
CTE vs Temp Table vs Table Variable
| Scenario | Use | Why |
|---|
| Readability / recursive queries / used once | CTE | Inline, no physical storage, optimizer sees through it |
| Large result set used multiple times in same batch | Temp Table | Has statistics, can be indexed, persists for the session |
| Small result set (< few hundred rows) | Table Variable | Low overhead; no statistics (can be a problem if used in joins) |
| Need an index on intermediate results | Temp Table | Table variables can only have a PK constraint |
| Need statistics for optimizer accuracy | Temp Table | Table variables have no statistics — optimizer assumes 1 row |
WITH RankedOrders AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY CustomerID ORDER BY OrderDate DESC) AS rn
FROM Orders
)
SELECT * FROM RankedOrders WHERE rn = 1;
SELECT OrderID, CustomerID, TotalAmount
INTO #LargeOrderSet
FROM Orders
WHERE TotalAmount > 10000;
CREATE INDEX IX_Temp_CustomerID ON #LargeOrderSet (CustomerID);
SELECT o.*, c.CompanyName FROM #LargeOrderSet o
JOIN Customers c ON o.CustomerID = c.CustomerID;
DROP TABLE #LargeOrderSet;
DECLARE @SmallSet TABLE (OrderID INT, CustomerID INT);
INSERT INTO @SmallSet SELECT TOP 10 OrderID, CustomerID FROM Orders;
Statistics Management
UPDATE STATISTICS dbo.Orders WITH FULLSCAN;
EXEC sp_updatestats;
SELECT name, is_auto_update_stats_on, is_auto_create_stats_on
FROM sys.databases
WHERE name = DB_NAME();
DBCC SHOW_STATISTICS ('dbo.Orders', 'IX_Orders_CustomerID');
When to run manually:
- After a large bulk load (auto-update threshold is 20% of rows modified — can lag on large tables)
- After index creation (statistics are created automatically but may not be full scan)
- When execution plans look wrong (row estimates far off from actuals)
Query Hints — When Justified
Query hints override optimizer decisions. Use sparingly — they lock in a behavior regardless of data changes.
SELECT * FROM Orders WITH (NOLOCK) WHERE CustomerID = 42;
SELECT * FROM Orders WHERE CustomerID = @CustomerID OPTION (RECOMPILE);
SELECT * FROM Orders WHERE CustomerID = @CustomerID OPTION (OPTIMIZE FOR (@CustomerID UNKNOWN));
SELECT SUM(TotalAmount) FROM Orders OPTION (MAXDOP 2);
SELECT * FROM Orders o JOIN Customers c ON o.CustomerID = c.CustomerID OPTION (FORCE ORDER);
Reference