SQL Execution Flow

What is it: SQL is declarative — you describe what you want, not how to get it. The database engine determines the actual execution plan. Understanding the logical execution order explains counterintuitive behaviors (why can't you use a SELECT alias in WHERE?).

Logical execution order:

graph TD A["1. FROM + JOIN<br/>identify and join source tables"] --> B["2. WHERE<br/>filter rows (before grouping)"] B --> C["3. GROUP BY<br/>group rows by key"] C --> D["4. HAVING<br/>filter groups (after aggregation)"] D --> E["5. SELECT<br/>compute output columns"] E --> F["6. DISTINCT<br/>remove duplicates"] F --> G["7. ORDER BY<br/>sort result"] G --> H["8. LIMIT / OFFSET<br/>truncate result"]

This is the logical order the SQL standard defines meaning by — not necessarily the literal order the engine physically executes steps in (the planner is free to reorder physical execution, e.g. pushing a WHERE filter down before a JOIN if that's cheaper, as long as the result is identical to running it in this logical order). What this order actually constrains is name resolution: an identifier used in step N can only refer to something already defined by an earlier step.

Why the order matters:

WHERE runs before SELECT, so you cannot use a SELECT alias in a WHERE clause:

-- WRONG: alias 'total' is not yet defined at WHERE stage
SELECT price * qty AS total FROM orders WHERE total > 100;

-- CORRECT
SELECT price * qty AS total FROM orders WHERE price * qty > 100;

HAVING runs after GROUP BY and after aggregate functions are computed, so you can use aggregates in HAVING but not in WHERE:

SELECT customer_id, SUM(amount) FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 1000;  -- correct: HAVING sees aggregate result

WHERE vs HAVING: WHERE filters individual rows before grouping — it can use indexes, reduces the number of rows entering GROUP BY, and is always faster. HAVING filters groups after aggregation — it cannot use row-level indexes because it operates on computed aggregates. Always push conditions to WHERE if they do not depend on an aggregate. HAVING SUM(amount) > 1000 cannot be rewritten as WHERE, but HAVING customer_id > 5 can and should be moved to WHERE.

Real-world usage: Misplacing logic in HAVING instead of WHERE is a common source of slow queries. In EXPLAIN ANALYZE, a WHERE filter applied early reduces the row count shown at each plan node; a HAVING filter is applied late, after all the aggregation work is done.

Common pitfall

Trying to reference a SELECT-clause alias in a GROUP BY clause works in some databases (PostgreSQL and MySQL allow it as an extension) but not because of the logical order above — strictly by the standard's logical order, GROUP BY (step 3) also runs before SELECT (step 5) defines the alias, the same problem WHERE has. Relying on a specific database's extension to this rule is a portability trap: code that works on PostgreSQL can fail outright on a database that follows the standard order strictly.