Skip to main content

    Advanced Track • Query Optimization

    Execution Plans & Cost Estimation

    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".

    What You'll Learn

    • Read EXPLAIN and EXPLAIN ANALYZE output line by line
    • Tell a seq scan from an index scan — and why it matters
    • Recognise nested loop vs hash vs merge joins
    • Understand how the optimiser turns statistics into cost
    • Rewrite queries to be sargable and index-friendly
    • Spot stale statistics and apply optimiser hints safely

    The Big Picture

    🗺️ Real-world analogy

    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.

    EXPLAIN vs EXPLAIN ANALYZE

    The plan only, then the plan with real timings.

    Try it Yourself »
    SQL
    -- 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.

    Two ways to answer one query

    Watch the plan flip from Seq Scan to Index Scan.

    Try it Yourself »
    SQL
    -- 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 W
    ...

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

    • Seq Scan on employees — the access method and the table.
    • cost=0.00..1850.00 — estimated cost to get the first row (0.00) and to finish (1850.00).
    • rows=1200 — the optimiser's estimate of how many rows survive the filter.
    • width=64 — average bytes per returned row.
    • Filter: (salary > 70000) — the condition applied to every row read. On an Index Scan this line becomes Index Cond, meaning the index itself did the filtering.

    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.

    Reading cost, rows and width

    What the three numbers on every node mean.

    Try it Yourself »
    SQL
    -- 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 mill
    ...

    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.

    Estimate vs actual

    A big mismatch points straight at stale statistics.

    Try it Yourself »
    SQL
    -- 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
    ...

    What the extra ANALYZE numbers mean

    • actual time=0.05..6.90 — real milliseconds to first row and to last row for this node.
    • rows=48000 (in the actual line) — rows this node really produced.
    • loops=1 — how many times the node ran. In a nested loop the inner node runs many times; the printed time and rows are per loop, so multiply by loops for the true total.
    • Rows Removed by Filter: 12000 — work the engine did and then threw away. Large numbers here mean a better index or predicate could avoid the wasted reads.

    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: sargable date range

    Replace the ___ blanks with the range bounds.

    Try it Yourself »
    SQL
    -- 🎯 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 
    ...

    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).

    Recognising join methods

    Map each join node to when it's the right choice.

    Try it Yourself »
    SQL
    -- 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 t
    ...

    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 high-impact rewrites

    Sargable predicates, covering columns, OR→UNION, EXISTS, aggregate-once.

    Try it Yourself »
    SQL
    -- 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 W
    ...

    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: choose the fix

    Add the index the WHERE filter needs.

    Try it Yourself »
    SQL
    -- 🎯 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
    ...

    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.

    Refreshing statistics

    ANALYZE, check freshness, and raise the histogram resolution.

    Try it Yourself »
    SQL
    -- 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
    ...

    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 across engines

    Session flags, comment hints, and OPTION — they all vary.

    Try it Yourself »
    SQL
    -- "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;    
    ...

    Common Errors (and the fix)

    • Function on an indexed column kills the index: WHERE YEAR(hire_date) = 2024 or WHERE UPPER(name) = 'ANA' forces a Seq Scan. Rewrite as a range on the bare column, or build a matching expression index on YEAR(hire_date).
    • Leading-wildcard LIKE can't use a B-tree: WHERE name LIKE '%son' scans the whole table because the index is sorted left-to-right. LIKE 'son%' (anchored at the start) can use the index.
    • Stale statistics produce wild estimates: if EXPLAIN ANALYZE shows estimated rows far from actual rows, run ANALYZE your_table; before blaming the query.
    • SELECT * defeats a covering index: if an index covers (customer_id, total) but you ask for every column, the engine must visit the table anyway. Select only the columns you need so the index alone can answer.
    • Reading the plan top-down: plans execute the most-indented node first. Read bottom-up, or you'll misjudge which step is the real bottleneck.

    📘 Quick Reference

    TermWhat it means
    EXPLAINShow the chosen plan — does not run the query
    EXPLAIN ANALYZERun the query and add real time + actual rows
    Seq ScanRead the whole table; bad when few rows match
    Index ScanUse an index to jump to matching rows
    Nested LoopPer outer row, look up the inner; tiny outer + indexed inner
    Hash JoinHash the small input, stream the large one through it
    Merge JoinSort both inputs, then zip them — both already sorted
    cost=a..bEstimated cost: a = first row, b = all rows (units, not ms)
    rows / widthEstimated row count / average bytes per row
    ANALYZERefresh statistics so estimates stay accurate

    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

    Rewrite + index a 5M-row query, then explain the new plan.

    Try it Yourself »
    SQL
    -- 🎯 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
    ...

    🎉 Lesson Complete

    • EXPLAIN shows the plan; EXPLAIN ANALYZE runs it and adds real times
    • ✅ A Seq Scan reads everything; an Index Scan jumps to matching rows
    • ✅ Cost, rows and width drive every decision — and all rest on statistics
    • ✅ Estimated-vs-actual rows is your fastest stale-stats diagnostic
    • ✅ Nested loop, hash and merge joins each win in different situations
    • ✅ Sargable predicates and column-only SELECTs unlock your indexes
    • Next: Advanced JOIN Patterns — semi-joins, anti-joins, and lateral joins

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service