Query Optimization

Reviewed & published by Brayan K

By the end of this lesson you'll be able to open up any slow query with EXPLAIN, read the plan line by line, understand how the cost-based optimiser decided what to do, and rewrite the query so it uses your indexes instead of scanning whole tables. This is the skill that separates "it works" from "it's fast at scale".

Part of the free SQL course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn

The Big Picture

A query is a destination; the optimiser is a satnav. You say where you want to go (the SQL); the optimiser works out how to get there — which roads (scans), which interchanges (joins), in which order. EXPLAIN prints the chosen route before you drive it. EXPLAIN ANALYZE drives it and logs the real travel time at every junction. Your job in this lesson is to read that route sheet and spot where the satnav took the slow road because it had a bad map (stale statistics) or because you wrote the address in a way it couldn't match to a fast road (a non-sargable predicate).

1. EXPLAIN — Print the Plan Before You Run It

The query planner (also called the optimiser) is the part of the database that turns your SQL into a concrete execution plan — an ordered tree of steps. EXPLAIN prints that tree without running the query. EXPLAIN ANALYZE actually executes it and adds the real timings and real row counts next to each step, so you can compare guess against reality.

Read a plan bottom-up and inside-out: the most-indented node runs first, and its output rows flow up into the node above it. The top line is the final result.

-- engine: postgres — this whole lesson is about reading query plans, and
-- EXPLAIN ANALYZE is PostgreSQL syntax that SQLite does not have at all
-- (its nearest equivalent is EXPLAIN QUERY PLAN, which prints something
-- quite different). Pressing Run on this site uses SQLite, so run these
-- against a real PostgreSQL server to see the plans below.
--
-- The table this lesson uses. Every later block queries it, and the
-- "Try it Yourself" button carries this setup along so each snippet runs.
-- manager_id points at another row's id; the CEO's is NULL, which is what
-- makes the org chart walkable.
CREATE TABLE employees (
    id         INTEGER PRIMARY KEY,
    name       TEXT,
    title      TEXT,
    manager_id INTEGER,
    salary     INTEGER,
    hire_date  TEXT,
    email      TEXT
);

INSERT INTO employees (id, name, title, manager_id, salary, hire_date, email) VALUES
    (1, 'Ada',   'Chief Executive',  NULL, 180000, '2019-02-11', '[email protected]'),
    (2, 'Brian', 'VP Engineering',      1, 140000, '2020-06-01', '[email protected]'),
    (3, 'Carla', 'VP Sales',            1, 135000, '2021-09-20', '[email protected]'),
    (4, 'Dan',   'Engineer',            2,  75000, '2024-01-15', '[email protected]'),
    (5, 'Eve',   'Junior Engineer',     4,  62000, '2024-07-08', '[email protected]');

-- EXPLAIN shows the plan the optimiser CHOSE — it does not run the query.
EXPLAIN
SELECT name, salary
FROM employees
WHERE salary > 70000;

-- EXPLAIN ANALYZE actually RUNS the query and adds the real timings,
-- so you can compare what the optimiser guessed against what happened.
EXPLAIN ANALYZE
SELECT name, salary
FROM employees
WHERE salary > 70000;

-- Read a plan bottom-up and inside-out: the most indented node runs first,
-- and its rows flow up into the node above it.

2. Seq Scan vs Index Scan

The first thing to find in a plan is how each table is read. A Seq Scan (sequential scan) reads every row in the table and throws away the ones that don't match — fine for tiny tables or when you genuinely want most rows. An Index Scan uses an index to jump straight to the matching rows — far less work when you want only a few.

-- The SAME query can be answered two very different ways.

-- (1) No useful index on salary -> the engine reads every row:
EXPLAIN SELECT * FROM employees WHERE salary > 70000;
-- Plan: Seq Scan on employees  (cost=0.00..1850.00 rows=1200 width=64)
--         Filter: (salary > 70000)
-- "Seq Scan" = sequential scan = read the whole table, throw away non-matches.

-- (2) Add an index that matches the predicate:
CREATE INDEX idx_emp_salary ON employees (salary);

EXPLAIN SELECT * FROM employees WHERE salary > 70000;
-- Plan: Index Scan using idx_emp_salary on employees
--         (cost=0.42..96.30 rows=1200 width=64)
--         Index Cond: (salary > 70000)
-- "Index Scan" jumps straight to the matching rows. Far less work.

-- Rule of thumb: a Seq Scan on a big table that returns FEW rows is a red flag.

Line-by-line: Seq Scan on employees (cost=0.00..1850.00 rows=1200 width=64)

3. Cost, Rows & Width — How the Optimiser Decides

The optimiser builds several candidate plans, assigns each a numeric cost, and keeps the cheapest. Cost is an abstract unit — not milliseconds — derived from a few tunable constants for disk reads and CPU work. The key insight: every estimate ultimately traces back to the row count the optimiser expects, which it gets from table statistics.

-- Every node is labelled cost=startup..total  rows=estimate  width=bytes.
EXPLAIN SELECT * FROM orders WHERE total > 500;
-- Seq Scan on orders  (cost=0.00..1250.00 rows=5000 width=64)
--   Filter: (total > 500)
--
-- cost=0.00..1250.00  -> 0.00 to return the FIRST row, 1250.00 for ALL rows.
-- rows=5000           -> the optimiser's GUESS of how many rows match.
-- width=64            -> average bytes per row (drives memory + I/O estimates).

-- Those numbers are abstract "cost units", not milliseconds. PostgreSQL bases
-- them on a handful of tunable constants:
--   seq_page_cost        = 1.0    (read one page in order)
--   random_page_cost     = 4.0    (read one page at a random spot — disks seek)
--   cpu_tuple_cost       = 0.01   (process one row)
--   cpu_index_tuple_cost = 0.005  (process one index entry)
-- Total cost is roughly: (pages read x page_cost) + (rows x cpu_cost).
-- The optimiser builds several candidate plans and keeps the CHEAPEST total.

4. Estimated Rows vs Actual Rows

This is the single most useful diagnostic in the whole lesson. EXPLAIN ANALYZE prints the estimated rows (from statistics) right beside the actual rows (from running the query). When they're close, the optimiser had a good map and its plan choice was sound. When they're off by 10x or more, the optimiser was flying blind — and almost certainly chose the wrong plan.

-- EXPLAIN ANALYZE prints BOTH the estimate and the reality. Compare them.
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'shipped';
-- Seq Scan on orders
--   (cost=0.00..1850.00 rows=200 width=72)            <- estimated 200 rows
--   (actual time=0.05..6.90 rows=48000 loops=1)       <- ACTUALLY 48000 rows
--   Filter: (status = 'shipped')
--   Rows Removed by Filter: 12000
--
-- estimate 200 vs actual 48000 = a 240x miss. The optimiser thought almost
-- nothing matched, so it skipped the index. That mismatch is the classic
-- symptom of STALE STATISTICS (covered in section 6).
-- loops=1 means the node ran once; in a nested loop it can run many times,
-- and the printed row count is PER loop.

What the extra ANALYZE numbers mean

Your Turn: make it sargable

A predicate is sargable (Search-ARGument-able) when the engine can use an index for it. Wrapping the indexed column in a function like YEAR() destroys that. Rewrite the year test as a half-open range on the bare column. The expected plan is in the comments so you can check yourself.

-- 🎯 YOUR TURN — make this query "sargable" (able to use an index).
-- The index on hire_date is useless here because YEAR() hides the raw column:
--   SELECT * FROM employees WHERE YEAR(hire_date) = 2024;
--
-- Rewrite it as a half-open RANGE on the bare column so the index can be used.

SELECT * FROM employees
WHERE hire_date >= ___          -- 👉 first instant of 2024
  AND hire_date <  ___;         -- 👉 first instant of 2025 (exclusive upper bound)

-- ✅ Expected: predicate uses hire_date directly, so EXPLAIN now shows
--    "Index Scan ... Index Cond: (hire_date >= '2024-01-01' AND hire_date < '2025-01-01')"
--    Fill the blanks with '2024-01-01' and '2025-01-01'.

5. Join Methods — Nested Loop, Hash, Merge

When two tables are joined, the optimiser picks one of three physical strategies. Each wins in a different situation, and the plan tells you which one it chose.

Nested Loop: for each row on the outer side, look up matches on the inner side. Best when the outer side is tiny and the inner side is indexed; awful for large-by-large because it's roughly outer × inner work. Hash Join: build a hash table from the smaller input, then stream the larger one through it — the default for big equality joins. Merge Join: sort both inputs on the join key and walk them together like a zip — best when both inputs already arrive sorted (both columns indexed).

-- A JOIN can be executed three ways. The optimiser picks based on table
-- sizes, indexes and available memory. Learn to recognise each in a plan.

EXPLAIN ANALYZE
SELECT c.name, o.total
FROM customers c                       -- small: ~1,000 rows
JOIN orders o ON c.id = o.customer_id  -- large: ~1,000,000 rows
WHERE o.total > 100;

-- You will see ONE of these as the join node:
--
-- Nested Loop      -> for each outer row, look up matches in the inner table.
--                     Great when the outer side is tiny AND the inner side is
--                     indexed. Terrible for large x large (it is O(n x m)).
--
-- Hash Join        -> build an in-memory hash table from the SMALLER input,
--                     then stream the larger input through it. The default for
--                     big equality joins when neither side is pre-sorted.
--
-- Merge Join       -> sort BOTH inputs on the join key, then walk them in step
--                     like a zip. Wins when both inputs already arrive sorted
--                     (e.g. both sides have an index on the join column).

6. Query Rewrites That Unlock Indexes

You influence the plan far more by how you write the query than by tweaking the optimiser. These five rewrites remove the most common reasons an index goes unused.

-- Five rewrites that routinely turn a slow plan into a fast one.

-- 1) SARGABLE PREDICATE — no function on the indexed column.
-- ❌ index on hire_date cannot be used:
SELECT * FROM employees WHERE YEAR(hire_date) = 2023;
-- ✅ bare column + range -> index usable:
SELECT * FROM employees
WHERE hire_date >= '2023-01-01' AND hire_date < '2024-01-01';

-- 2) SELECT ONLY THE COLUMNS YOU NEED — lets an index "cover" the query.
-- ❌ forces a trip back to the table for every row:
SELECT * FROM orders WHERE customer_id = 42;
-- ✅ if (customer_id, total) is indexed, this reads the index ALONE:
SELECT customer_id, total FROM orders WHERE customer_id = 42;

-- 3) REPLACE OR (across columns) WITH UNION — each branch uses its own index.
-- ❌ an OR over two columns usually defeats both indexes:
SELECT * FROM products WHERE category = 'Electronics' OR brand = 'Apple';
-- ✅ split so each side is a clean, index-friendly lookup:
SELECT * FROM products WHERE category = 'Electronics'
UNION
SELECT * FROM products WHERE brand = 'Apple';

-- 4) EXISTS vs IN — EXISTS can stop at the first match.
-- IN materialises the whole subquery; EXISTS short-circuits as soon as one
-- matching row is found, which is faster for "does any related row exist?":
SELECT c.name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

-- 5) AGGREGATE ONCE, NOT PER ROW — kill correlated subqueries.
-- ❌ runs the COUNT once for EVERY customer row:
SELECT name, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) FROM customers c;
-- ✅ one grouped pass over the data:
SELECT c.name, COUNT(o.id) AS order_count
FROM customers c LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;

Your Turn: fix the seq scan

The plan below reads all 2,000,000 rows to return just 12. The predicate is already sargable (a bare customer_id), so the issue isn't the query — there's simply no index to use. Write the one statement that fixes it.

-- 🎯 YOUR TURN — EXPLAIN below shows a Seq Scan returning only 12 of 2,000,000
-- rows. Reading the whole table to find 12 rows is the problem. Choose the fix.
--
--   EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
--   Seq Scan on orders  (cost=0.00..36000.00 rows=12 width=64)
--     Filter: (customer_id = 42)
--
-- Write the ONE statement that lets a future run use an Index Scan instead:

___   -- 👉 create an index on the column used in the WHERE filter

-- ✅ Expected: CREATE INDEX idx_orders_customer ON orders (customer_id);
--    Re-running EXPLAIN then shows:
--    "Index Scan using idx_orders_customer ... Index Cond: (customer_id = 42)".

7. Statistics — The Optimiser's Map of Your Data

Every cost estimate ultimately rests on statistics: how many rows a table has, how many distinct values a column holds, which values are most common, and a histogram of the distribution. The database samples these periodically. After a big bulk load, delete, or import, the sample is out of date — stale — and the estimates (remember the 200-vs-48000 miss) go badly wrong, taking the plan with them.

-- The optimiser's row estimates come from STATISTICS it samples per table:
-- how many rows, how many distinct values, the most common values, histograms.
-- If the data changed a lot since the last sample, the estimates go stale and
-- the plans go wrong (remember the 200-vs-48000 miss above).

-- PostgreSQL: refresh statistics by hand (autovacuum also does this for you):
ANALYZE orders;

-- See when each table was last analysed and how big it is:
SELECT relname, last_analyze, last_autoanalyze, n_live_tup
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;

-- For a skewed column, store a finer histogram, then re-analyse:
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;

-- MySQL is similar:  ANALYZE TABLE orders;
-- After a big bulk load or delete, ANALYZE first — THEN trust EXPLAIN.

8. Optimiser Hints (Use Sparingly)

Sometimes the optimiser still gets it wrong and you need to override it with a hint. There's no SQL standard for hints, so the syntax differs by engine — and a hint that helps today's data can hurt tomorrow's. Fix indexes, predicates and statistics first; treat hints as a last-resort splint.

-- "Hints" let you override the optimiser. SYNTAX VARIES BY ENGINE — there is
-- no standard, so a hint that works on one database is ignored or rejected on
-- another. Reach for them only after fixing indexes, predicates and statistics.

-- PostgreSQL has no inline hints; you nudge the planner with session flags:
SET enable_seqscan = off;   -- discourage sequential scans for this session
EXPLAIN SELECT * FROM employees WHERE salary > 70000;  -- see what it picks now
SET enable_seqscan = on;    -- always turn it back on afterwards

-- MySQL / Oracle use comment-style hints right after SELECT:
--   SELECT /*+ INDEX(orders idx_orders_customer) */ * FROM orders WHERE customer_id = 42;
--   SELECT /*+ NO_INDEX(t idx_x) */ ...

-- SQL Server uses an OPTION clause:
--   SELECT * FROM orders WHERE customer_id = 42 OPTION (RECOMPILE);

-- Treat hints as a temporary splint. A hint that fixes today's data often
-- becomes tomorrow's bug when the data distribution shifts.

Common Errors (and the fix)

Frequently Asked Questions

Q: Is a Seq Scan always a problem?

No. If your query returns most of the table, one sequential pass is cheaper than millions of random index look-ups, so the optimiser correctly picks a Seq Scan. It's only a smell when a large table is scanned to return a few rows.

Q: Are the cost numbers in milliseconds?

No — they're abstract units derived from page-read and CPU constants. Use them to compare two plans, not as a stopwatch. For real time, use EXPLAIN ANALYZE and read the actual time values.

Q: EXISTS or IN — which is faster?

For "does any related row exist?", EXISTS can stop at the first match, while IN may materialise the whole subquery. On modern optimisers they're often planned identically, but EXISTS is the safer default for correlated existence checks.

Q: My estimate and actual rows are wildly different. What now?

Run ANALYZE your_table; to refresh statistics, then re-check. If a column is very skewed, raise its statistics target (SET STATISTICS 1000) and analyse again so the histogram captures the distribution.

Q: Do these plans look the same in MySQL or SQL Server?

The concepts (scans, joins, cost, statistics) are universal, but the wording and exact numbers differ. MySQL's EXPLAIN formats results differently and uses comment-style hints; SQL Server shows graphical plans and uses OPTION. Learn the ideas here and the engine-specific syntax maps over easily.

Mini-Challenge: Optimise the Slow Report

Now do it with the support removed — a brief, a blank canvas, and the expected shape in the comments. Make the date test sargable, add one supporting index, and explain how the plan changes. Then paste it into a playground to confirm.

-- 🎯 MINI-CHALLENGE — optimise a slow report, then explain the plan change.
--
-- The query below is slow on a 5,000,000-row orders table:
--
--   SELECT *
--   FROM orders
--   WHERE YEAR(created_at) = 2024
--     AND status = 'shipped';
--
-- EXPLAIN shows: Seq Scan on orders, Filter on both conditions, ~9s.
--
-- 1. Rewrite the date test to be SARGABLE (range on the bare created_at).
-- 2. Add ONE index that supports both conditions, most selective column first.
-- 3. In a comment, say which scan node you now expect and why it is faster.
--
-- ✅ Expected (one correct shape):
--    CREATE INDEX idx_orders_status_created ON orders (status, created_at);
--    SELECT * FROM orders
--    WHERE status = 'shipped'
--      AND created_at >= '2024-01-01' AND created_at < '2025-01-01';
--    -- Plan flips Seq Scan -> Index Scan (Index Cond on status + created_at):
--    -- it jumps to the 'shipped' block and range-reads only 2024, instead of
--    -- scanning and filtering all 5,000,000 rows.

-- your solution here

🎉 Lesson Complete

Practice quiz

What does plain EXPLAIN do?

  • Runs the query and shows real timings
  • Rebuilds the table's statistics
  • Prints the chosen execution plan without running the query
  • Creates an index automatically

Answer: Prints the chosen execution plan without running the query. EXPLAIN prints the plan the optimiser chose without running the query. EXPLAIN ANALYZE actually runs it and adds real timings.

How should you read a query plan?

  • Bottom-up and inside-out — the most-indented node runs first
  • Top-down, left to right
  • Alphabetically by node name
  • In random order

Answer: Bottom-up and inside-out — the most-indented node runs first. Read a plan bottom-up and inside-out: the most-indented node runs first and its rows flow up into the node above it.

When is a Seq Scan a red flag?

  • When it returns 90% of the table
  • On a tiny table
  • Whenever it appears, always
  • When a large table is scanned to return only a few rows

Answer: When a large table is scanned to return only a few rows. A Seq Scan isn't always bad — scanning is cheaper when you want most of the table. The warning sign is a Seq Scan on a large table returning only a handful of rows.

What makes a predicate 'sargable'?

  • It wraps the indexed column in a function
  • The engine can use an index for it — no function on the bare indexed column
  • It always uses a Seq Scan
  • It returns exactly one row

Answer: The engine can use an index for it — no function on the bare indexed column. A sargable predicate lets the engine use an index. Wrapping the column in a function like YEAR(hire_date) destroys that; use a range on the bare column instead.

Why does WHERE YEAR(hire_date) = 2024 prevent index use?

  • The index stores raw dates, not years, so the function hides the indexed column
  • YEAR is not a valid function
  • Indexes can never be used with dates
  • It returns too many rows

Answer: The index stores raw dates, not years, so the function hides the indexed column. The index stores raw hire_date values, not their year. Rewrite as a half-open range (hire_date >= '2024-01-01' AND hire_date < '2025-01-01') so the index can be used.

Which join method builds a hash table from the smaller input and streams the larger through it?

  • Nested Loop
  • Merge Join
  • Hash Join
  • Seq Scan

Answer: Hash Join. A Hash Join builds an in-memory hash table from the smaller input, then streams the larger input through it — the default for big equality joins.

When is a Nested Loop join the right choice?

  • Large table joined to large table
  • When the outer side is tiny and the inner side is indexed
  • When both inputs are already sorted
  • When neither side has an index

Answer: When the outer side is tiny and the inner side is indexed. A Nested Loop looks up matches in the inner table for each outer row — great when the outer side is tiny and the inner side is indexed, but terrible for large-by-large.

Are the cost numbers in an EXPLAIN plan measured in milliseconds?

  • Yes, they are exact milliseconds
  • Yes, but only for Seq Scans
  • No, they are byte counts
  • No — they are abstract cost units used to compare plans, not a stopwatch

Answer: No — they are abstract cost units used to compare plans, not a stopwatch. Cost is an abstract unit derived from page-read and CPU constants, used to compare plans. For real time, read the actual time values from EXPLAIN ANALYZE.

What should you do when EXPLAIN ANALYZE shows estimated rows far from actual rows?

  • Add more columns to the SELECT
  • Run ANALYZE on the table to refresh statistics
  • Drop all indexes
  • Increase the cost constants

Answer: Run ANALYZE on the table to refresh statistics. A big estimate-vs-actual mismatch is the classic symptom of stale statistics. Run ANALYZE your_table; to refresh them before blaming the query.

Why does a leading-wildcard LIKE such as WHERE name LIKE '%son' scan the whole table?

  • Because LIKE never uses indexes
  • Because '%son' is invalid syntax
  • Because a B-tree index is sorted left-to-right, so a leading wildcard can't use it
  • Because it returns no rows

Answer: Because a B-tree index is sorted left-to-right, so a leading wildcard can't use it. A B-tree index is sorted left to right, so a leading wildcard defeats it. An anchored pattern like LIKE 'son%' can use the index.

Continue this course