Query Execution Order
You type SELECT first. The database runs it fifth. Step through the real order and watch the row count change at every stage.
Overview
Written order versus execution order
A query is written in this order:
SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT
It is executed in this one:
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
The database first works out which rows it is dealing with (FROM and JOIN), filters them (WHERE), groups what survives (GROUP BY), filters the groups (HAVING), and only then computes the output columns (SELECT). Sorting and limiting happen last, on the finished result.
Step Through It
Press Step to run the first stage: FROM.
Try Breaking It
The Query, Written
—The Order It Actually Runs
Rows At This Stage
Row Count
Query Execution Order in SQL: A Practical Guide
SQL is written SELECT first and executed SELECT almost last. Nearly every confusing SQL error - unknown alias, aggregate in WHERE - is explained by that gap.
The two errors this explains
"Column does not exist" on an alias in WHERE:
SELECT price * quantity AS line_total
FROM order_lines
WHERE line_total > 100; -- errorWHERE runs at step 2 and SELECT at step 5, so line_total does not exist yet. Repeat the expression, or wrap the query:
SELECT * FROM (
SELECT price * quantity AS line_total FROM order_lines
) t WHERE line_total > 100;"Aggregate not allowed in WHERE":
SELECT customer_id, SUM(total)
FROM orders
WHERE SUM(total) > 1000 -- error
GROUP BY customer_id;The sum is produced at step 3; WHERE runs at step 2. HAVING is the clause that runs after grouping, and is therefore the one that can see it.
The mirror image is why ORDER BY can use an alias: it runs at step 6, after SELECT has created it.
WHERE and HAVING are not interchangeable
Both filter, at different stages, and the distinction matters for correctness as well as speed:
- WHERE filters individual rows, before grouping. Excluded rows never reach the aggregate.
- HAVING filters groups, after aggregation. All rows contribute to the aggregate; whole groups are then discarded.
When a condition could go in either — a plain column filter — put it in WHERE. Filtering earlier means fewer rows to group, which is almost always faster.
Written order versus the order things happen
SQL is written in an order designed to read like English and executed in a different one. Almost every confusing error message in SQL comes from that mismatch.
| Written | Executed |
|---|---|
1. SELECT | 5. SELECT |
2. FROM / JOIN | 1. FROM / JOIN |
3. WHERE | 2. WHERE |
4. GROUP BY | 3. GROUP BY |
5. HAVING | 4. HAVING |
6. ORDER BY | 6. ORDER BY |
7. LIMIT | 7. LIMIT |
So the real sequence is: assemble the rows, filter them, group them, filter the groups, work out what to display, sort, then cut.
(Strictly, this is the logical order. The planner may physically do things in another order entirely — pushing a filter down into an index scan, for instance — as long as the result is what this sequence would produce.)
Following one query through all seven steps
SELECT c.country, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'complete'
GROUP BY c.country
HAVING SUM(o.total) > 5000
ORDER BY revenue DESC
LIMIT 5;FROM/JOIN— build the combined set of order-and-customer rows. If 50,000 orders match 8,000 customers, this is where the row count is decided.WHERE— drop everything not complete. Say 30,000 rows survive.GROUP BY— collapse those into one row per country. Perhaps 40 rows now.HAVING— drop countries under 5,000 in revenue. Perhaps 12 rows.SELECT— compute and name the output columns, including the aliasesordersandrevenue.ORDER BY— sort those 12 rows by revenue, using the alias created one step earlier.LIMIT— keep the top 5.
Reading it this way answers most "why is this slow?" questions too: the expensive steps are the ones operating on the largest row counts, which are almost always the join and the filter. Narrowing step 2 helps everything after it.
Exploration guide
- Step through a query. Press Step repeatedly and watch which clause runs at each stage. FROM produces the rows, WHERE removes some, GROUP BY collapses the rest, and SELECT is near the end.
- Break it deliberately. Enable the option that uses a SELECT alias in WHERE. It fails, because at that point the alias has not been created.
- Then use the same alias in ORDER BY. Enable that option instead. It works — same alias, different clause, and the execution order is the only reason.
- Reset and compare. Press Reset and run a working query, watching how many rows survive each stage. Filtering early leaves far less work for everything downstream.
What usually goes wrong
- Using a SELECT alias in WHERE or GROUP BY. Repeat the expression, or wrap the query in a subquery or CTE where the alias already exists.
- Putting an aggregate in WHERE. Use HAVING.
- Using HAVING for row filters. Correct results, more work — the rows are grouped before being discarded.
- Expecting LIMIT to speed up an aggregate. LIMIT runs last, so the database has already grouped and sorted everything before it truncates.
- Filtering an outer join in WHERE. A condition on the right-hand table in WHERE discards the NULL rows and silently turns the LEFT JOIN into an INNER JOIN. Put it in the ON clause instead.
Key takeaway
SQL executes FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT — not the order it is written in. That single fact explains why SELECT aliases work in ORDER BY but not WHERE, why aggregates belong in HAVING rather than WHERE, and why filtering early is faster. When a query does something surprising, walking it through the execution order usually answers it in one step.
Where the other clauses fit
Several things do not appear in the classic seven-step list and need placing.
DISTINCTruns afterSELECTand beforeORDER BY. That is whySELECT DISTINCT x FROM t ORDER BY yfails in strict engines —ywas removed before the sort could use it.- Window functions are computed after
HAVINGand beforeDISTINCT. They can see the grouped rows, andWHERE/HAVINGcannot see them. Filtering on a window function always needs an outer query. - CTEs (
WITH) are conceptually evaluated first, as named inputs to the main query, though modern planners inline them and optimise across the boundary. OFFSETruns withLIMIT, at the very end, after everything has been produced and sorted — which is exactly why deep offsets are slow.
Putting those together, the fuller order is:
FROM → WHERE → GROUP BY → HAVING → window functions → SELECT → DISTINCT → ORDER BY → LIMIT/OFFSET
What the planner is allowed to change
The logical order defines the result, not the physical work. The optimiser rewrites freely as long as the answer is identical:
- Predicate pushdown — a
WHEREcondition can be applied while scanning a table, before the join, rather than after it. - Join reordering — inner joins can be executed in any order; the planner picks the one with the smallest intermediate results.
- Index-provided ordering — if an index already returns rows in the requested order, step 6 costs nothing.
- Early termination — with
LIMITand a matching index, the engine can stop as soon as it has enough rows, without producing the full result.
This is why EXPLAIN output does not look like the seven steps: it shows the physical plan. The logical order is what you reason about; the plan is what actually ran.
The order it is written in is not the order it runs
SQL is written SELECT first and evaluated SELECT almost last. Most of the confusing errors about aliases and aggregates follow from that -- and this engine is more forgiving than the standard, in a way worth knowing before you move the query somewhere stricter.
Questions people ask
Why can ORDER BY use an alias but WHERE cannot? Because SELECT (which creates aliases) runs after WHERE and before ORDER BY.
Does LIMIT make the query faster? Sometimes dramatically — with a matching index the engine can stop early. Without one, it may still have to produce and sort everything first.
Is SELECT * slower? It transfers more data and can prevent index-only scans, so on wide tables, yes. Name the columns you need.
Where do subqueries fit? A subquery in FROM is evaluated as part of step 1; one in WHERE as part of step 2; a correlated one runs in the context of the row being tested.
Does this order apply to every database? The logical order is standard and consistent across engines. Which relaxations they allow — aliases in GROUP BY, for instance — varies.
Why does my query return more rows after adding a join? Because step 1 happens first, and a one-to-many join multiplies rows before any filtering or grouping gets a chance to run.
Recap in one screen
- Execution order is
FROM,WHERE,GROUP BY,HAVING, window functions,SELECT,DISTINCT,ORDER BY,LIMIT. - Aliases created in
SELECTare unavailable inWHEREandHAVING, and available inORDER BY. - Aggregates do not exist until
GROUP BYhas run, which is why they belong inHAVING. - The join happens first, so it decides the row count everything else works on.
- The planner may reorder the physical work freely — read
EXPLAINfor what actually happened.