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 writeThe order SQL logically runs
SELECTFROM / JOIN
FROMWHERE
WHEREGROUP BY
GROUP BYHAVING
HAVINGSELECT (+ window functions)
ORDER BYDISTINCT
LIMITORDER 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:

  1. FROM / JOINBuild the working set of rows by reading tables and combining them.
  2. ONApply the join condition that matches rows between tables.
  3. WHEREFilter individual rows. Aliases and aggregates are not available yet.
  4. GROUP BYCollapse the surviving rows into groups.
  5. HAVINGFilter the groups (this is where aggregates like COUNT() can be filtered).
  6. SELECTCompute output columns, expressions, aliases and window functions.
  7. DISTINCTRemove duplicate rows from the selected output.
  8. ORDER BYSort the final result. Aliases from SELECT are available here.
  9. LIMIT / OFFSETTrim the sorted result to the requested slice.
Mental model: read a query as if it started with 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:

error.sql
-- ❌ 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:

fix.sql
-- ✅ 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.

where-vs-having.sql
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
Rule of thumb: if a condition is about a single row's raw value, it belongs in 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 BY can use SELECT aliases. It runs after SELECT, so aliases already exist — the opposite of WHERE.
  • DISTINCT happens after SELECT. It de-duplicates the already-computed output columns.
  • Window functions run in the SELECT step, after GROUP BY and HAVING. So you can't put a window function (like ROW_NUMBER()) in WHERE either. Wrap it in a subquery/CTE, or use QUALIFY in databases that support it (e.g. Snowflake, BigQuery).
order-by-alias.sql
-- ✅ 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:

DatabaseRow-limit syntax
MySQL / PostgreSQL / SQLiteLIMIT n OFFSET m
SQL ServerSELECT TOP n or OFFSET … FETCH NEXT n ROWS ONLY
OracleFETCH FIRST n ROWS ONLY
Note: a database's physical execution plan (chosen by the optimizer) may reorder work for performance, but it must always produce results consistent with this logical order. The logical order is what your query means; the plan is how the engine delivers it.

Common mistakes & quick fixes

  • Using a SELECT alias in WHERE. Fix: repeat the expression, or use a subquery/CTE.
  • Putting an aggregate in WHERE (e.g. WHERE COUNT(*) > 5). Fix: move it to HAVING.
  • Filtering a window function in WHERE. Fix: wrap the query, then filter in the outer WHERE (or use QUALIFY).
  • Expecting WHERE to shrink groups. WHERE filters rows; group-level conditions go in HAVING.
  • Assuming LIMIT runs before ORDER BY. Sorting happens first — LIMIT takes 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.
  • WHERE runs before SELECT, so SELECT aliases and aggregates aren't available in WHERE.
  • WHERE filters rows before grouping; HAVING filters groups after.
  • ORDER BY runs after SELECT, so it can use aliases.
  • The logical order is the same in every major SQL database — only LIMIT/TOP/FETCH syntax differs.