Logical Query Processing Order
The order SQL evaluates your query, which matters more than you think.
SQL has a quirk that surprises almost every newcomer: you write a query starting with SELECT, but the database does not process SELECT first. There is a specific logical order in which each clause is evaluated, and it differs significantly from the order you write them.
Understanding this order is not a trivia question. It explains several behaviors that otherwise seem arbitrary, including why some references to column aliases work and others do not, and why the placement of a filter condition changes the result.
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. Keeping that reversal in mind resolves most confusion.
Aliases cannot be used before SELECT runs
Because SELECT runs at step 5, any aliases you define there do not exist in earlier steps:
-- 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 fix is either to repeat the expression in WHERE, or to wrap the query in a CTE or subquery so the alias is defined before the outer filter runs.
ORDER BY is the exception: it runs at step 7, after SELECT, so aliases defined in SELECT are available there:
SELECT salary * 12 AS annual
FROM employees
ORDER BY annual DESC; -- works fine
WHERE 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;
At the time WHERE executes, the rows have not been grouped yet. There is no "count per department" to filter on. HAVING runs after grouping, when those counts exist.
Joins and the ON vs WHERE distinction
The FROM step is where joins are assembled. A condition placed in a join's ON clause is applied during the join itself. A condition placed in WHERE is applied 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'
In the first version, the join condition restricts which orders are included, but all customers remain. In the second version, the WHERE clause filters out any customer row where no matching order was found, effectively converting the left join into an inner join.
Thinking in execution order
When a query behaves unexpectedly, trace it through the execution order. Ask: at the step where this clause runs, which data is available? What has already been filtered or grouped? This mental model turns confusing behavior into predictable, explainable outcomes.