CS360 Engineering Blog

Insights, updates, and technical deep dives from the CS360 engineering team.

SQL Server Query Optimization: From Tactics to Principles

Your application is crawling. You’re ready to throw money at new servers.

Stop. Before you spend thousands on hardware, check your queries. A single WHERE clause with a function can waste 95% of your I/O. A missing index on a join column can turn a 0.1-second query into a 30-second nightmare.

The good news? These problems aren’t random. Almost every slow query violates one of six fundamental principles. Learn these, and you won’t memorize rules—you’ll understand why they exist.

This guide abandons tactics for principles, then uses execution plans as the Rosetta Stone to see why SQL Server behaves the way it does.


Six Principles That Explain Everything

Principle 1: Preserve SARGability (Search ARGument Ability)

If SQL Server cannot turn your predicate into a range or point lookup, it must scan.

Mental model: Indexes are ordered structures. The optimizer can only jump to a position if it knows the bounds before reading rows.

-- SARGable: Defines bounds
WHERE OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01'

-- Not SARGable: Destroys ordering
WHERE YEAR(OrderDate) = 2025        -- Wraps column in function
WHERE UPPER(LastName) = 'SMITH'     -- Function destroys order
WHERE Salary * 1.1 > 50000          -- Math on column
WHERE LEN(PhoneNumber) = 10         -- Length function
WHERE Email LIKE '%gmail.com'       -- Leading wildcard

Why it matters: When you apply a function to a column, SQL Server cannot use the index because the function destroys the column’s original ordering. The index is ordered by OrderDate, not YEAR(OrderDate). These are not the same universe.

What happens internally:

Read row → Materialize OrderDate → Execute YEAR() → Compare to 2025 → Repeat N times

The optimizer has no choice but to scan every row and calculate the function for each one.

The fix:

WHERE OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01'     -- Date range
WHERE LastName = 'Smith'                                         -- No function
WHERE Salary > 50000 / 1.1                                       -- Math on constant
WHERE PhoneNumber LIKE '[0-9][0-9][0-9]-[0-9][0-9][0-9]...'    -- No leading wildcard

Plan indicator: Table Scan on large tables = lost SARGability.

Impact: 8 seconds → 0.1 seconds (80x improvement).


Principle 2: Reduce Data Movement Relentlessly

SQL Server is fast at processing, slow at moving.

Mental model: The most expensive operations are:

Why it matters: A covering index is not clever because it’s an index. It’s fast because it avoids extra reads.

-- Regular index: Find rows via index, then KEY LOOKUP back to table for columns
CREATE INDEX idx_Status ON Orders(Status);
SELECT OrderDate, Total FROM Orders WHERE Status = 'Shipped';  -- Slow

-- Covering index: Everything needed is in the index
CREATE INDEX idx_Status_Covering ON Orders(Status)
INCLUDE (OrderDate, Total);
SELECT OrderDate, Total FROM Orders WHERE Status = 'Shipped';  -- Fast
-- SELECT *: Over-fetches, wastes bandwidth
SELECT * FROM Employees;  -- 30 columns, 5KB per row, 500MB for 100K rows

-- Specific columns: Only what you need
SELECT EmployeeID, FirstName, LastName, Email FROM Employees;  -- 4 columns, 200 bytes, 20MB

Plan indicators:

Impact: 2.5 seconds → 0.1 seconds (25x improvement).


Principle 3: Let the Optimizer See the Whole Query

If the optimizer can’t reason about your logic, it guesses. Badly.

Mental model: The optimizer is a compiler. Inline TVFs are macros. Multi-statement TVFs are opaque DLLs returning a mystery box with “~100 rows” written on it.

Why it matters:

-- Multi-statement TVF (Slow)
CREATE FUNCTION dbo.GetActiveUsers(@MinSales INT)
RETURNS @Result TABLE (UserID INT, SalesCount INT)
AS
BEGIN
    INSERT INTO @Result
    SELECT UserID, COUNT(*) FROM Sales GROUP BY UserID
    HAVING COUNT(*) >= @MinSales;
    RETURN;
END;

-- SQL Server assumes ~100 rows (guess). Cannot push predicates inside.
-- Result: Wrong join strategy, no index usage. 12 seconds.

-- Inline TVF (Fast)
CREATE FUNCTION dbo.GetActiveUsers(@MinSales INT)
RETURNS TABLE
AS
RETURN (
    SELECT UserID, COUNT(*) AS SalesCount FROM Sales 
    GROUP BY UserID
    HAVING COUNT(*) >= @MinSales
);

-- SQL Server merges this into your query. Uses proper indexes. 0.3 seconds.

What happens internally: Multi-statement TVFs prevent the optimizer from:

You didn’t write a slow function. You removed the optimizer’s eyesight.

The fix: Always prefer inline TVFs.

Plan indicator: Constant Scan or Constant Table Scan with estimate “100 rows” = multi-statement TVF.

Impact: 12 seconds → 0.3 seconds (40x improvement).


Principle 4: Stop Work As Early As Possible

Queries can ask boolean questions or take a census. Choose carefully.

Mental model:

-- Slow: Must count all matching rows
SELECT * FROM Customers c
WHERE (SELECT COUNT(*) FROM Orders o WHERE o.CustomerID = c.CustomerID) > 0;

-- Fast: Stops after first match
SELECT * FROM Customers c
WHERE EXISTS (
    SELECT 1 FROM Orders o WHERE o.CustomerID = c.CustomerID
);

What happens internally: COUNT(*) compiles as an aggregate operator. The optimizer cannot short-circuit aggregates, so it:

  1. Scans all qualifying rows
  2. Maintains an aggregate counter
  3. Only then compares to zero

EXISTS compiles to a semi-join, which has a built-in “stop after first hit” rule.

Different operators. Different physics.

Plan indicator: Stream Aggregate with large input = using COUNT(*) when EXISTS would suffice.

Impact: Proportional to result set size. Small result set = minor. Large result set = dramatic.


Principle 5: Shape Access Paths, Not Just Queries

Queries don’t “run fast.” Access paths do.

Mental model: Your SQL text is just a hint. The optimizer chooses the access path. Choose wisely, or accept default paths that don’t fit your data.

Why it matters:

-- OFFSET: Iterate
SELECT * FROM Orders ORDER BY OrderID 
OFFSET 50000 ROWS FETCH NEXT 100 ROWS ONLY;
-- Must: Read, Sort, Discard 50K rows, Return 100
-- Every page repeats the work. Page 1000: 12 seconds

-- Seek-based pagination: Position
SELECT TOP 100 * FROM Orders 
WHERE OrderID > @LastOrderIDFromPreviousPage
ORDER BY OrderID;
-- Find starting key, Read forward. Page 1000: 0.05 seconds

This isn’t an optimization trick. It’s the difference between iteration and positioning.

Indexing shapes access paths:

-- Columns in WHERE: Enable seeks
CREATE INDEX idx_Status ON Orders(Status);

-- Columns in JOIN: Enable efficient join strategies
CREATE INDEX idx_CustomerID ON Orders(CustomerID);

-- Columns in ORDER BY: Enable index-based sort
CREATE INDEX idx_OrderDate ON Orders(OrderDate);

What happens internally (OFFSET):

Read all rows → Sort by OrderID → Skip to row 50001 → Read rows 50001-50100 → Return
(Repeats for every page)

What happens internally (Seek-based):

Jump to key > @LastOrderID via index → Read 100 rows forward → Return

Plan indicator:

Impact: 200x faster on later pages.


Principle 6: Respect the Cost Model (Statistics + Cardinality)

The optimizer is not smart. It is statistically confident.

Mental model: The optimizer compiles one plan per query (cached). It bases that plan on:

  1. Parameter values (first execution)
  2. Column statistics histogram
  3. Assumed selectivity

If later executions violate those assumptions, the cached plan may be catastrophically wrong.

-- Parameter sniffing: First run with @Status = 'Shipped' (99.9% of rows)
-- Gets "Hash Join" plan
-- Next run with @Status = 'Cancelled' (0.1% of rows)
-- Wrong plan cached. Nested Loop would be better. Plan doesn't recompile.

SELECT * FROM Orders WHERE Status = @Status;

-- Solution 1: Force recompile
SELECT * FROM Orders WHERE Status = @Status
OPTION (RECOMPILE);

-- Solution 2: Break parameter binding (optimizer can't sniff)
DECLARE @LocalStatus VARCHAR(20) = @Status;
SELECT * FROM Orders WHERE Status = @LocalStatus;

What happens internally:

  1. First execution: Optimizer sees @Status = 'Shipped'. Estimates 50M rows (99.9%). Chooses Hash Join.
  2. Second execution: Cached plan reused. Expects 50M rows. Actually finds 50K. Incorrect parallelism, memory grant, join strategy.

Statistics decay: Old statistics → wrong confidence → wrong plan.

UPDATE STATISTICS OrdersTable;  -- Refresh histogram
EXEC sp_updatestats;             -- Refresh all

Plan indicator:

Impact: “It was fast yesterday” = statistics changed or parameter sniffing.


Execution Plans: The Rosetta Stone

You don’t need to memorize 12 rules if you learn to read plans causally.

One question beats all others: Why did the optimizer choose this operator instead of a seek?

Plan Smell Underlying Violation Principle
Table Scan (large table) Predicate not SARGable 1
Key Lookup Index not covering 2
Sort (OFFSET) Wrong access path 5
Stream Aggregate (large input) Using COUNT(*) instead of EXISTS 4
Constant Scan ~100 rows Multi-statement TVF 3
Memory Grant Warning Statistics outdated 6
Hash Match on small input Cardinality misestimate 6

If you internalize this mapping, the checklist becomes unnecessary.


Quick Checklist


The Uncomfortable Truth

Most of these aren’t “mistakes.” They’re impedance mismatches between developer intuition and relational algebra.

People think row-by-row. Databases think sets, orderings, and probabilities.

Once you align with that, the rules evaporate—and the plans start making sense.

If you don’t understand why the plan looks like this, you are not done with the query.

Hardware scaling treats symptoms. Query plans explain disease.

Happy Coding!!!