The order you write vs the order SQL runs
Almost everyone learns to write a query in this order: SELECT, then FROM, then WHERE, and so on. It's natural to assume the database reads it top to bottom the same way. It doesn't.
SQL is a declarative language: you describe the result you want, and the engine decides how to produce it. Internally it follows a fixed logical query processing order that is different from the written order. Once you know that order, a whole category of "why doesn't this work?" errors suddenly makes sense.
| The order you write | The order SQL logically runs |
|---|---|
| SELECT | FROM / JOIN |
| FROM | WHERE |
| WHERE | GROUP BY |
| GROUP BY | HAVING |
| HAVING | SELECT (+ window functions) |
| ORDER BY | DISTINCT |
| LIMIT | ORDER BY |
| LIMIT / OFFSET |
The full SQL execution order, step by step
Here is the complete logical order the database evaluates a SELECT statement in. Each step takes the rows produced by the previous step as its input:
- FROM / JOINBuild the working set of rows by reading tables and combining them.
- ONApply the join condition that matches rows between tables.
- WHEREFilter individual rows. Aliases and aggregates are not available yet.
- GROUP BYCollapse the surviving rows into groups.
- HAVINGFilter the groups (this is where aggregates like COUNT() can be filtered).
- SELECTCompute output columns, expressions, aliases and window functions.
- DISTINCTRemove duplicate rows from the selected output.
- ORDER BYSort the final result. Aliases from SELECT are available here.
- LIMIT / OFFSETTrim the sorted result to the requested slice.
FROM. Data flows FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT, with each stage narrowing or reshaping what the next stage sees.Why WHERE runs before SELECT
The database has to answer one question before anything else: which rows are we even talking about? It can't compute output columns, run aggregates, or sort results until it knows the final set of qualifying rows.
So the flow is: FROM gathers candidate rows, WHERE throws out the ones that don't match, and only then does SELECT shape what's left into the columns you asked for. Filtering first is also far more efficient — there's no point computing expensive expressions for rows that are about to be discarded.
The #1 consequence: no SELECT aliases in WHERE
Because SELECT runs after WHERE, any alias you define in SELECT simply doesn't exist when WHERE is evaluated. This is the single most common beginner error that the execution order explains:
-- ❌ Fails: "annual_salary" is unknown in WHERE
SELECT name, salary * 12 AS annual_salary
FROM employees
WHERE annual_salary > 100000;
There are two clean fixes. Repeat the expression (since WHERE can evaluate the raw columns), or push it into a subquery/CTE so the alias is materialized before the outer WHERE runs:
-- ✅ Option A: repeat the expression
SELECT name, salary * 12 AS annual_salary
FROM employees
WHERE salary * 12 > 100000;
-- ✅ Option B: subquery / CTE so the alias exists first
SELECT name, annual_salary
FROM (
SELECT name, salary * 12 AS annual_salary
FROM employees
) t
WHERE annual_salary > 100000;
WHERE vs HAVING: filter before vs after grouping
The execution order also settles the classic WHERE-vs-HAVING confusion. WHERE filters rows before GROUP BY; HAVING filters groups after aggregation. That's why aggregate functions like COUNT() work in HAVING but not in WHERE.
SELECT department, COUNT(*) AS headcount
FROM employees
WHERE active = 1 -- row filter: runs BEFORE grouping
GROUP BY department
HAVING COUNT(*) > 5; -- group filter: runs AFTER grouping
WHERE. If it's about an aggregate of a group, it belongs in HAVING.ORDER BY, DISTINCT & window functions
A few more rules fall straight out of the order:
ORDER BYcan useSELECTaliases. It runs afterSELECT, so aliases already exist — the opposite ofWHERE.DISTINCThappens afterSELECT. It de-duplicates the already-computed output columns.- Window functions run in the
SELECTstep, afterGROUP BYandHAVING. So you can't put a window function (likeROW_NUMBER()) inWHEREeither. Wrap it in a subquery/CTE, or useQUALIFYin databases that support it (e.g. Snowflake, BigQuery).
-- ✅ ORDER BY runs after SELECT, so the alias is valid here
SELECT name, salary * 12 AS annual_salary
FROM employees
ORDER BY annual_salary DESC;
Is it the same in MySQL, PostgreSQL & SQL Server?
Yes — the logical execution order is defined by the SQL standard and is identical across MySQL, PostgreSQL, SQL Server, Oracle, SQLite and friends. What differs is only surface syntax, mainly for limiting rows:
| Database | Row-limit syntax |
|---|---|
| MySQL / PostgreSQL / SQLite | LIMIT n OFFSET m |
| SQL Server | SELECT TOP n or OFFSET … FETCH NEXT n ROWS ONLY |
| Oracle | FETCH FIRST n ROWS ONLY |
Common mistakes & quick fixes
- Using a
SELECTalias inWHERE. Fix: repeat the expression, or use a subquery/CTE. - Putting an aggregate in
WHERE(e.g.WHERE COUNT(*) > 5). Fix: move it toHAVING. - Filtering a window function in
WHERE. Fix: wrap the query, then filter in the outerWHERE(or useQUALIFY). - Expecting
WHEREto shrink groups.WHEREfilters rows; group-level conditions go inHAVING. - Assuming
LIMITruns beforeORDER BY. Sorting happens first —LIMITtakes its slice from the sorted result.
Key takeaways
- SQL runs in a fixed logical order: FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT.
WHEREruns beforeSELECT, soSELECTaliases and aggregates aren't available inWHERE.WHEREfilters rows before grouping;HAVINGfilters groups after.ORDER BYruns afterSELECT, so it can use aliases.- The logical order is the same in every major SQL database — only
LIMIT/TOP/FETCHsyntax differs.