Logical Query Processing Order
The order SQL evaluates your query, which matters more than you think.
Here is the thing that trips up almost every SQL learner: you write a query starting with SELECT, but the database does not process SELECT first. The clause you type first is evaluated fifth. That gap between how you write a query and how SQL actually runs it is responsible for a surprising number of "why doesn't this work?" moments.
Once you understand the logical processing order, those moments stop feeling arbitrary. They become predictable.
The logical processing order
| Step | Clause | What happens |
|---|---|---|
| 1 | FROM | Identify the source tables; evaluate joins |
| 2 | WHERE | Filter individual rows |
| 3 | GROUP BY | Collect matching rows into groups |
| 4 | HAVING | Filter groups based on aggregate conditions |
| 5 | SELECT | Compute output columns and apply aliases |
| 6 | DISTINCT | Remove duplicate rows (if specified) |
| 7 | ORDER BY | Sort the result |
| 8 | LIMIT / OFFSET | Return a subset of the sorted result |
The written order is SELECT, FROM, WHERE. The execution order is FROM, WHERE, SELECT. Pin that reversal somewhere in your head - it explains almost everything that follows.
Aliases cannot be used before SELECT runs
You will write this at some point and wonder why it fails:
-- Error: "annual" is not defined when WHERE runs (step 2)
SELECT salary * 12 AS annual
FROM employees
WHERE annual > 100000;
-- Correct: repeat the expression in WHERE
SELECT salary * 12 AS annual
FROM employees
WHERE salary * 12 > 100000;The alias annual does not exist yet when WHERE runs. SELECT - the step that creates the alias - comes later. So either repeat the expression, or wrap the query in a CTE so the alias is defined before the outer filter sees it.
ORDER BY is the exception here. It runs at step 7, after SELECT, so your aliases are fair game:
SELECT salary * 12 AS annual
FROM employees
ORDER BY annual DESC; -- works fineWHERE vs HAVING
WHERE runs before grouping (step 2). HAVING runs after grouping (step 4). This is why you cannot use an aggregate function in WHERE:
-- Error: aggregate functions cannot appear in WHERE
SELECT department, COUNT(*)
FROM employees
WHERE COUNT(*) > 10
GROUP BY department;
-- Correct: HAVING filters after the groups are formed
SELECT department, COUNT(*)
FROM employees
GROUP BY department
HAVING COUNT(*) > 10;When WHERE runs, the rows have not been grouped yet. There is no "count per department" to filter on - those counts do not exist until step 3. HAVING runs at step 4, when the groups are formed and the aggregates are ready.
Joins and the ON vs WHERE distinction
This is probably the subtlest part of the order, and it catches even experienced people.
A condition placed in a join's ON clause applies during the join itself. A condition placed in WHERE applies after the join is assembled. For an inner join the result is identical either way. For a left join, it is not:
-- Filters during the join: customers without matching orders still appear (with NULLs)
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
AND o.created_at >= '2024-01-01'
-- Filters after the join: customers without matching orders are removed
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.created_at >= '2024-01-01'The first version keeps all customers - it just limits which orders are included. The second version removes any customer that had no qualifying order, which quietly turns your left join into an inner join. A common bug.
Thinking in execution order
When a query behaves unexpectedly, walk through the steps. At the point where this clause runs, what data exists? Has it been filtered? Grouped? This single habit - tracing the order - converts most SQL confusion into something you can reason about clearly.