Skip to main content
ANVISoftware Solutions
Lesson 19 of 22Advanced19 min

Query Optimisation

By the end of this lesson

Rewrite queries measurably rather than by superstition.

Query tuning attracts folklore. "Always use EXISTS instead of IN." "Never use SELECT *." "UNION is slow." Some of these are sound, some were true on a database version from fifteen years ago, and some were never true.

The way to tell them apart is to measure. Every rewrite in this lesson should be verified on your data, because the right answer genuinely depends on the data distribution, the indexes and the server version.

The loop that separates tuning from guessing:

  1. Establish a baseline

    Record logical reads and elapsed time for the current query, from a warm second run. Without a baseline, you cannot tell an improvement from normal variation.

  2. Read the actual plan and form one hypothesis

    "This scan is here because the date filter is wrapped in YEAR()." A specific, falsifiable statement beats a general intention to make things faster.

  3. Change one thing

    One rewrite, or one index. Two at once and you cannot attribute the result, and you may keep a change that made things worse.

  4. Measure the same way

    Same warm-run conditions, same metrics. Compare logical reads first — they are much less noisy than elapsed time on a shared server.

  5. Confirm the result is still correct

    A faster query that returns different rows is not an optimisation. Compare row counts and spot-check values, especially after changing a join or adding DISTINCT.

  6. Keep it or revert it

    If the gain is within measurement noise, revert. Complexity that does not buy anything is a cost paid by every future reader.

Rewrites that reliably help

Four changes with a real mechanism behind them
SQL
-- 1. Make the filter searchable: let the index be used
-- Before
WHERE YEAR(o.order_date) = 2026 AND UPPER(c.country) = 'INDIA'
-- After (with a case-insensitive collation, the UPPER is unnecessary)
WHERE o.order_date >= '2026-01-01' AND o.order_date < '2027-01-01'
  AND c.country = 'India'

-- 2. Filter before joining, not after
-- Before: joins every order, then discards most of the result
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.customer_id
WHERE o.order_date >= '2026-01-01'
-- After: only 2026 orders ever enter the join
FROM customers AS c
JOIN (
    SELECT order_id, customer_id, shipping_fee
    FROM orders
    WHERE order_date >= '2026-01-01'
) AS o ON o.customer_id = c.customer_id

-- 3. Ask an existence question instead of counting
-- Before: counts every matching row, then compares
WHERE (SELECT COUNT(*) FROM orders AS o WHERE o.customer_id = c.customer_id) > 0
-- After: stops at the first match
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.customer_id)

-- 4. Return the columns you use, not every column
-- Before
SELECT * FROM orders WHERE customer_id = 417
-- After: can be answered from a covering index alone
SELECT order_id, order_date, status FROM orders WHERE customer_id = 417
  • Rewrite 1 has the clearest mechanism: removing the function on the column allows an index seek. This is the single most productive rewrite in most codebases.
  • Rewrite 2 is worth an honest caveat. A good optimiser often pushes the WHERE down on its own, so the two forms may produce identical plans. Where it helps is when the filter cannot be pushed down — across an outer join, or before an aggregation. Check the plan rather than assuming.
  • Rewrite 3 is sound because EXISTS can stop at the first matching row while COUNT(*) must find them all. On a customer with 4,000 orders that is 3,999 rows of unnecessary work.
  • Rewrite 4 matters for two reasons: fewer bytes over the network, and the chance that a narrow index covers the query so the table is never read.

Advice that is weaker than its reputation

These appear in tuning checklists and deserve more scepticism than they usually get:

"EXISTS is always faster than IN"
For a non-correlated subquery returning a modest list, modern optimisers frequently produce the same plan for both. EXISTS is still preferable for a different reason: NOT IN breaks when a NULL appears, and NOT EXISTS does not.
"Add DISTINCT to remove duplicates"
Duplicates after a join almost always mean the join multiplied rows. DISTINCT hides that and adds a sort or hash over the whole result. Find out why the rows repeated — the answer is often that you needed one fewer table or an aggregation first.
"Use a temporary table to break up a query"
Sometimes a real win: it materialises an intermediate result, gives it statistics, and stops the optimiser guessing. Sometimes a real loss: it writes to disk, blocks pipelining, and adds a step. Measure both forms.
"Rewrite the subquery as a join"
Worth trying, and not a rule. Joins can multiply rows where a subquery cannot, and the optimiser often converts between the two internally anyway.
"Use a query hint to force the good plan"
It works until the data changes, and then it prevents the optimiser from adapting. Occasionally justified, always with a comment explaining what was measured and when to revisit it.

The two problems that dwarf query rewriting

Before tuning a query, check that you are tuning the right thing. Two patterns cost more than any amount of clever SQL, and both are invisible in a single query's plan.

The first is querying in a loop. An application fetches 500 orders, then fetches each order's customer individually. That is 501 round trips, and each one pays network latency. Every individual query looks fast in the plan. The page takes eight seconds. The fix is one query with a join, and with an ORM it usually means loading the related data explicitly rather than lazily.

The second is fetching data you do not use. Returning 50,000 rows to count them in application code, or selecting 40 columns to display 4. The database can count and it can project — asking it to is usually a single-line change with a large effect.

One more effect worth knowing by name, because it produces a genuinely confusing symptom. A database caches the plan for a parameterised query, built for the parameter values it saw first. If those values were unusual — a customer with three orders, when most have thousands — the cached plan can be badly suited to later calls.

This is parameter sniffing. The symptom is a query that is fast for most inputs and slow for some, or fast for weeks and then slow after a restart, with no code change. Diagnosing it is beyond this lesson; recognising the pattern stops you looking for a bug in the SQL that is not there.

Summary

  • Tune with a baseline, one change at a time, and compare logical reads rather than raw timings
  • The reliable rewrites have a mechanism: make filters searchable, reduce rows early, use EXISTS instead of counting, select only the columns you need
  • Duplicates after a join are a signal to examine the join, not to add DISTINCT
  • Querying in a loop and fetching unused data cost more than most query rewrites, and neither shows up in one plan
  • A tuning claim needs a number attached; a query's behaviour depends on data, indexes and cached plans, not on the SQL text alone

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

This query supports a monthly statement. Identify every reason it might be slow, then rewrite it. State what you would measure to confirm each change.

SELECT DISTINCT c.*, o.order_id FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items oi ON oi.order_id = o.order_id WHERE YEAR(o.order_date) = 2026 AND MONTH(o.order_date) = 3 AND UPPER(c.country) = 'INDIA';

Show solution

Four separate problems. YEAR() and MONTH() on order_date prevent an index seek, so the whole orders table is read. UPPER() on country does the same for any index on customers.country, and is usually unnecessary because the default collation in SQL Server is case-insensitive.

The join to order_items serves no purpose: no column from it appears in the output. It multiplies each order by its line count, which is exactly why DISTINCT was needed — the DISTINCT is a symptom, not a fix. Removing the join removes the duplicates and the DISTINCT together.

c.* returns every customer column for every row. Naming the four the statement displays reduces the data transferred and gives a narrow index a chance to cover the query.

What to measure: logical reads per table before and after, from STATISTICS IO. Expect the orders reads to drop sharply once the date filter can seek, and the order_items reads to go to zero once the join is gone. Then confirm the row count matches the original after accounting for the removed duplicates.

Confirming correctness matters here because removing DISTINCT and a join changes the result shape. Run both versions and compare counts before trusting the new one.

SQL
SELECT
    c.customer_id,
    c.company_name,
    c.country,
    o.order_id,
    o.order_date
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.customer_id
WHERE o.order_date >= '2026-03-01'
  AND o.order_date <  '2026-04-01'
  AND c.country = 'India'
ORDER BY c.company_name, o.order_date;

-- Supporting index, if the plan shows a scan on orders
CREATE NONCLUSTERED INDEX ix_orders_date_customer
    ON orders (order_date)
    INCLUDE (customer_id);

Think about it

Think about it

A colleague reports that they made a report 10 times faster by replacing IN with EXISTS. You look at the plans and they are identical. What are the likely explanations, and how would you find out which?

Show solution

If the plans are identical, the rewrite is not what changed the timing. The most likely explanation is a warm cache: the first measurement read data from disk, the second found it in memory.

A second possibility is that the optimiser recompiled the plan when the query text changed, and the new plan happens to be better — not because EXISTS is better, but because the previous cached plan was built for unrepresentative parameter values. That is parameter sniffing, and the same effect would follow from any change to the query text, including adding a comment.

A third is ordinary variation on a busy server. A single before-and-after timing is not a measurement.

How to find out: run both versions twice each and compare the second runs, and compare logical reads rather than elapsed time. If reads are identical, the queries are doing the same work and the difference was environmental.

Worth saying plainly to the colleague: EXISTS is still the better habit, for correctness rather than speed, because NOT IN breaks on nulls. Keeping the rewrite for that reason is fine. Recording it as a 10x performance win is what would mislead the next person.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A query returns duplicate rows after joining orders to order_items, and no column from order_items is selected. What is the better fix?

Saved in this browser only.