September 5, 2026

The order SQL actually runs in

You write SELECT first, but it runs almost last. The logical processing order, and the errors that stop being mysterious once you know it.

A SQL query is written in one order and executed in another. Almost every "why doesn't this work" error — an alias that isn't recognized, an aggregate that's rejected, a window function that throws — is the database telling you that the thing you referenced doesn't exist yet at the step where you used it. Learn the step order and those errors stop being mysterious.

The order the engine actually uses

Regardless of how you write it, a SELECT is logically processed roughly like this:

  1. FROM / JOIN — assemble the working set of rows from the source tables.
  2. WHERE — throw away rows that don't match. No groups exist yet, no aggregates, no SELECT aliases.
  3. GROUP BY — collapse the surviving rows into one row per distinct group key.
  4. HAVING — throw away whole groups that don't match. Aggregates are available here because groups now exist.
  5. Window functionsROW_NUMBER, SUM(...) OVER (...), etc., computed over the grouped/filtered result.
  6. SELECT — evaluate the output expressions and assign their aliases. This is where amount * 1.1 AS with_tax first means anything.
  7. DISTINCT — drop duplicate output rows.
  8. ORDER BY — sort. Because it runs after SELECT, it can see aliases.
  9. LIMIT / OFFSET — take the final slice.

You write step 6 first. The engine runs it seventh. Everything below follows from that gap.

Why WHERE can't see a SELECT alias

SELECT amount * 1.1 AS with_tax
FROM orders
WHERE with_tax > 100;   -- error on Postgres, SQL Server, MySQL

WHERE is step 2; the alias with_tax isn't created until step 6. On most engines this is a hard error (column "with_tax" does not exist). The fix is to repeat the expression, or wrap the query in a subquery / CTE so the alias exists in the outer step:

SELECT * FROM (
  SELECT amount * 1.1 AS with_tax FROM orders
) t
WHERE with_tax > 100;

SQLite — and therefore the playground on this site — is unusually lenient here and will let you use an output alias in WHERE. That's a portability trap: a query that runs fine in the playground can still break on Postgres or SQL Server for exactly this reason. The execution-order rule is the one to internalize; SQLite bending it doesn't make it safe to rely on.

Why you can't filter an aggregate in WHERE

SQL playground
Loading editor…
⌘/Ctrl + Enter

misuse of aggregateWHERE (step 2) runs before GROUP BY (step 3), so there are no groups to sum over yet. Filtering on an aggregate is what HAVING (step 4) is for:

SQL playground
Loading editor…
⌘/Ctrl + Enter

Why a window function in WHERE is an error

SQL playground
Loading editor…
⌘/Ctrl + Enter

misuse of window function — window functions are step 5, after WHERE. There's no way to filter on one in the same query level. Push it down a level with a CTE and filter in the outer query, where step 6's alias is visible to the outer step 2:

SQL playground
Loading editor…
⌘/Ctrl + Enter

This is exactly why the top-N-per-group recipe is always written as a subquery or CTE — there's no shorter form.

Why WHERE filters rows and HAVING filters groups

They look similar but sit on opposite sides of GROUP BY, so they mean different things. WHERE decides which rows go into the groups:

SQL playground
Loading editor…
⌘/Ctrl + Enter

Every count and sum here is over paid orders only — the refunded and pending rows were gone before grouping happened. Move that same condition to HAVING and it would instead keep every row in the group and then drop groups by an aggregate condition. Most queries want WHERE for row conditions and HAVING only for conditions on aggregates — and you can use both at once, WHERE first, then HAVING.

The one place aliases do work early: ORDER BY

Because ORDER BY is step 8 — after SELECT — it can see output aliases, on every engine:

SQL playground
Loading editor…
⌘/Ctrl + Enter

ORDER BY total works where WHERE total > x wouldn't, and it's not an inconsistency — it's the step order showing through.

Quick reference

StepClauseWhat's available to reference
1FROM / JOINsource table columns
2WHEREsource columns only — no aliases, no aggregates, no window fns
3GROUP BYsource columns
4HAVINGsource columns + aggregates
5window functionsaggregates + group keys
6SELECTeverything above; aliases get created here
7DISTINCTthe output rows
8ORDER BYoutput columns including aliases
9LIMIT / OFFSET
  • WHERE and HAVING — the two filter steps, before and after grouping.
  • GROUP BY — the collapse step in the middle.
  • Subqueries and CTEs — how to add a step so a later-stage value becomes filterable.
  • Query Doctor — paste a query that's failing for one of these reasons and get the specific fix.
The aggregate and window-function errors above are real — run them, then run the fixed version underneath.Open the playground →