SQL execution order for query optimization
The logical order MySQL runs your SELECT — FROM/JOIN, WHERE, GROUP BY, HAVING, SELECT, ORDER BY/LIMIT — and the optimization hook each stage gives you.
On this page
A SELECT does not run in the order you wrote it. The lexical order is SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, but the logical evaluation order is closer to FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Knowing which stage your filter or sort happens in tells you which index will actually be used.
The query below is the running example for the rest of the note.
SELECT customer_id,
COUNT(order_id) AS total_orders,
SUM(order_amount) AS total_spent
FROM customers
JOIN orders ON customers.id = orders.customer_id
WHERE order_date >= '2023-01-01'
GROUP BY customer_id
HAVING total_spent >= 1000
ORDER BY total_spent DESC
LIMIT 10;The diagram below is the logical execution order — rows enter from the source tables at the top and pass through each stage, with row counts collapsing as filters and aggregates drop survivors. Each visual stripe stands for roughly 100 rows of real data, so the numeric labels track realistic table sizes. Press ▶ Play to walk through one stage at a time and read what MySQL actually does at each step.
How MySQL runs your SELECT
Press ▶ Play to watch the engine execute the query top to bottom, one stage at a time. Each stage processes the rows that survived the stage above it.
The diagram shows the seven stages MySQL runs for every SELECT, in the order
they actually execute — not the order you wrote them in. Press ▶ Play to walk
through one stage at a time, or click any band or clause to jump there.
The six stages
-
FROM and JOIN — pick tables, pair rows
The engine resolves the tables and joins them according to the
ONpredicate. The join cost is dominated by the right-hand table’s lookup strategy: an indexedONcolumn means aneq_ref/refprobe; an unindexed one degrades to a hash or nested-loop scan. Index the join columns on both sides, and make sure their data types match — aBIGINTjoined to aVARCHARdefeats the index. -
WHERE — filter rows, keep them SARGable
The
WHEREpredicate runs against the joined rows. A predicate is SARGable (Search ARGument ABLE) when it can use an index directly — that requires leaving the indexed column bare on one side of the comparison. Wrap the column in a function (YEAR(x) = 2008) and the index is useless; the engine falls back to a full scan and evaluates the function row-by-row. -
GROUP BY — aggregate the surviving rows
Aggregation runs after filtering. If the
GROUP BYcolumns match a leading prefix of an index, MySQL can stream-aggregate; otherwise it builds a temporary table or sort. Composite indexes that cover both theWHEREcolumns and theGROUP BYcolumns avoid the temp table. -
HAVING — filter the aggregates
HAVINGfilters afterGROUP BY. It cannot use a row-level index because the values it filters on (SUM,COUNT, …) only exist post-aggregation. If a predicate doesn’t reference an aggregate, move it toWHEREinstead — that drops rows earlier and shrinks the input toGROUP BY. -
SELECT — project columns
The projection runs last among the data-shaping stages. A covering index that contains every column referenced in
SELECT,WHERE, andORDER BYlets the engine answer the query without touching the table data. That’s the difference betweenUsing index(good) andUsing index conditionfollowed by row lookups (worse). -
ORDER BY and LIMIT — sort and cut
ORDER BYon an unindexed column triggersUsing filesort, which materializes the entire result set in memory or on disk before sorting. An index whose key order matchesORDER BYlets the engine walk the index in order and stop afterLIMITrows — turning a 5M-row sort into 10 reads.
SARGable rewrites
The single most common index-killer is a function or arithmetic on the indexed column. Three concrete rewrites:
-- function on the indexed column
SELECT ... WHERE YEAR(my_date) = 2008;
-- substring on the indexed column
SELECT ... WHERE SUBSTRING(dealer_name, 1, 4) = 'Ford';
-- arithmetic on the indexed column
SELECT ... WHERE DATEDIFF(NOW(), order_date) >= 30; -- range against the bare column
SELECT ... WHERE my_date >= '2008-01-01'
AND my_date < '2009-01-01';
-- prefix match
SELECT ... WHERE dealer_name LIKE 'Ford%';
-- arithmetic moved to the constant side
SELECT ... WHERE order_date < NOW() - INTERVAL 30 DAY; Where each stage’s wins compound
| Axis | Without indexes | With the right composite index |
|---|---|---|
| JOIN | Nested loop with full scan of inner table per outer row | eq_ref / ref probe — one row per match |
| WHERE | Full table scan, function applied per row | Index range scan, function never runs |
| GROUP BY | Builds temp table, then sorts | Streams aggregation in index order |
| ORDER BY + LIMIT | Filesort over the entire result set | Walks the index, stops after LIMIT rows |
What to remember
The execution order is the rulebook; the optimizer rewrites the plan but cannot violate it. Each stage gives you a different optimization hook — index the join column, keep WHERE SARGable, cover the GROUP BY, project from the index, and match ORDER BY to index direction. Confirm what the optimizer actually does with 0007 Reading MySQL EXPLAIN output · MySQL . When the predicate is “does a related row exist?”, reach for 0006 EXISTS instead of JOIN · MySQL ; when it’s “this column in a small set”, see 0008 IN vs OR · MySQL .