Scenario 3: The Non-SARGable Date Trap
Antipattern: Wrapping a column in a function in the WHERE clause (
YEAR(OrderDate) = 2024). Symptom: An index exists, but the execution plan shows a full Index Scan. Fix: Rewrite the predicate so the column is bare โ use a range instead.
The Story
The monthly reports dashboard had a filter for "orders in a given month." The developer wrote what felt natural:
An index on OrderDate exists. The query should be fast. But the execution plan shows a full Index Scan of 55,000+ rows. Every single order is read and evaluated.
The index is useless because SQL Server can't pre-evaluate YEAR() โ it doesn't know which index pages to skip.
Understanding SARGability
SARG = Search ARGument. A predicate is SARGable if SQL Server can use it to seek into an index.
The rule is simple: the column must appear alone on one side of the comparison.
-- โ Non-SARGable โ column is inside a function
WHERE YEAR(OrderDate) = 2024
WHERE CONVERT(DATE, OrderDate) = '2024-06-01'
WHERE UPPER(LastName) = 'SMITH'
WHERE OrderID + 0 = @ID -- arithmetic on the column
-- โ
SARGable โ column is bare
WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01'
WHERE OrderDate >= '2024-06-01' AND OrderDate < '2024-06-02'
WHERE LastName = 'Smith' -- use correct collation
WHERE OrderID = @ID
The Bad SP
CREATE OR ALTER PROCEDURE dbo.usp_Bad_GetOrdersByMonth
@Year INT,
@Month INT
AS
BEGIN
SET NOCOUNT ON;
SELECT o.OrderID, o.CustomerID, o.OrderDate, o.Status, o.TotalAmount
FROM dbo.Orders o
WHERE YEAR(o.OrderDate) = @Year -- โ Index-killer
AND MONTH(o.OrderDate) = @Month; -- โ Index-killer
END;
The Exercise
Step 1: Enable statistics and actual execution plan:
Enable the actual plan before running:
| Tool | How to enable actual execution plan |
|---|---|
| VS Code MSSQL | Right-click โ "Run Query with Actual Execution Plan" |
| DataGrip | Right-click โ Explain Plan โ Explain Analyzed |
Step 2: Run the bad version:
In the Messages/Output tab, note the logical reads:
In the Execution Plan tab, look for Index Scan on Orders โ SQL Server is reading every single row and applying the YEAR()/MONTH() functions per-row.
Step 3: Run the fixed version:
The Messages/Output now shows dramatically fewer logical reads. The execution plan shows Index Seek โ jumping directly to the matching date range. Hover the seek node to confirm the Seek Predicate shows a range (OrderDate >= '2024-06-01' AND OrderDate < '2024-07-01') rather than a function call.
Comparing the Plans
Bad execution plan:
Index Scan [IX_Orders_OrderDate] โ Reading ALL rows, applying function per-row
โณ Predicate: YEAR(OrderDate) = 2024 AND MONTH(OrderDate) = 6
โณ Estimated Rows: 55,000 Actual Rows: 4,600 (SQL doesn't know what YEAR() returns)
Fixed execution plan:
Index Seek [IX_Orders_OrderDate] โ Jump directly to the date range
โณ Seek Predicate: OrderDate >= '2024-06-01' AND OrderDate < '2024-07-01'
โณ Estimated Rows: 4,600 Actual Rows: 4,600 (statistics match)
The Fixed SP
CREATE OR ALTER PROCEDURE dbo.usp_Fixed_GetOrdersByMonth
@Year INT,
@Month INT
AS
BEGIN
SET NOCOUNT ON;
-- โ
Calculate boundaries in T-SQL โ keep the column bare in the WHERE
DECLARE @StartDate DATETIME2 = DATEFROMPARTS(@Year, @Month, 1);
DECLARE @EndDate DATETIME2 = DATEADD(MONTH, 1, @StartDate);
SELECT o.OrderID, o.CustomerID, o.OrderDate, o.Status, o.TotalAmount
FROM dbo.Orders o
WHERE o.OrderDate >= @StartDate -- โ
Column is bare โ SARGable
AND o.OrderDate < @EndDate; -- โ
Exclusive upper bound (no end-of-day edge cases)
END;
Note the < vs <= on the end date. Using < first-of-next-month handles every time of day on the last day of the month perfectly โ no 23:59:59.997 edge case bugs.
More Non-SARGable Examples You'll Encounter
-- โ Implicit conversion (varchar column filtered with integer)
WHERE StringID = 12345 -- SQL casts every row, kills index
-- โ ISNULL wrapping the column
WHERE ISNULL(Status, 'Unknown') = 'Unknown'
-- โ
Fix:
WHERE Status IS NULL OR Status = 'Unknown'
-- โ CAST on the column
WHERE CAST(OrderID AS NVARCHAR) = @StringParam
-- โ
Fix: cast the parameter, not the column
WHERE OrderID = CAST(@StringParam AS INT)
-- โ Negation of a function
WHERE CHARINDEX('error', LogMessage) > 0 -- full scan
-- โ
Fix: Use Full-Text index with CONTAINS if this is a core query
WHERE CONTAINS(LogMessage, 'error')
Interview Answer
"SARGable stands for Search ARGument Able โ it means the predicate can be used to seek into an index rather than scanning. The key rule is that the column must appear alone on one side of the comparison. Wrapping a column in a function like
YEAR(OrderDate)orUPPER(Name)makes the predicate non-SARGable because SQL Server has to evaluate the function for every single row. The fix is to keep the column bare and move the logic to the other side โ calculate the date boundaries in variables and use a range predicate. It goes from an Index Scan of 55,000 rows to an Index Seek of 4,600, which you can see immediately as a 10ร drop in logical reads."