1. Introduction
One of the most common sources of confusion for SQL learners — and a frequent source of bugs even for experienced developers — is the difference between how you write a SQL query and how the database engine actually executes it.
You write SQL in a human-readable order: SELECT at the top, FROM below it, then WHERE, GROUP BY, HAVING, ORDER BY, and LIMIT. But the database engine does not execute clauses in that order. It processes them in a completely different sequence — a logical order determined by the relational algebra the query is built on.
Understanding the execution order explains why you cannot reference a SELECT alias in a WHERE clause, why aggregate functions are unavailable in WHERE but allowed in HAVING, why DISTINCT runs after SELECT, and many other behaviours that seem mysterious at first glance. This blog walks through every step of the logical execution order with examples, output tables, and practical implications.
2. The Nine Steps — Logical Execution Order
The diagram below shows all nine steps in the logical order of execution, the role of each clause, and a side-by-side comparison of the writing order versus the execution order.

Figure 1: SQL Query — Logical Order of Execution (9 steps with alias visibility & writing order comparison)
Quick Summary
| Step | Clause | What Happens | SELECT Alias Visible? |
| 1 | FROM | Load source tables; compute Cartesian product of all listed tables | No |
| 2 | JOIN / ON | Apply join conditions to filter the Cartesian product | No |
| 3 | WHERE | Filter individual rows — no aggregates, no SELECT aliases | No |
| 4 | GROUP BY | Collapse rows matching the same group key into one row per group | No |
| 5 | HAVING | Filter groups — aggregate functions ARE available here | No |
| 6 | SELECT | Evaluate expressions, compute aggregates, define column aliases | Yes (defined here) |
| 7 | DISTINCT | Remove duplicate rows from the SELECT result | Yes |
| 8 | ORDER BY | Sort the result — SELECT aliases are visible | Yes |
| 9 | LIMIT/TOP | Return only the first N rows of the sorted result | Yes |
3. Step 1 — FROM: Building the Initial Dataset
The FROM clause is the very first thing the database processes. It identifies every table or subquery involved in the query and creates an initial working dataset. When multiple tables are listed with commas (old-style join), the engine produces their Cartesian product — every row from table A paired with every row from table B.
In practice, the optimizer never literally materialises the full Cartesian product for a JOIN — it uses index lookups, hash joins, or merge joins. But logically, FROM defines the universe of rows that all subsequent steps work with.
-- FROM is processed firstSELECT *FROM employees e -- Step 1a: load employeesJOIN departments d -- Step 1b: Cartesian product with departments ON e.dept_id = d.id; -- Step 2: apply JOIN condition to filter
Subqueries and CTEs in the FROM clause are also materialised at this step — their inner query is run first, then treated as a virtual table.
4. Step 2 — JOIN / ON: Applying Join Conditions
After FROM builds the initial row set, JOIN conditions (ON clause) are applied to combine rows from different tables. This step determines which rows from each table survive into the working dataset for subsequent steps.
Different join types produce different results at this step:
| Join Type | Rows Included |
| INNER JOIN | Only rows that match the ON condition in BOTH tables |
| LEFT JOIN | All rows from the left table; NULLs for unmatched right rows |
| RIGHT JOIN | All rows from the right table; NULLs for unmatched left rows |
| FULL OUTER JOIN | All rows from both tables; NULLs where no match |
| CROSS JOIN | Cartesian product — every left row paired with every right row |
-- Sample tables for the examples throughout this blog-- employees: emp_id, name, dept_id, salary, hire_year-- departments: id, dept_name, location, budgetSELECT e.name, d.dept_name, e.salaryFROM employees eJOIN departments d ON e.dept_id = d.id -- ON runs at Step 2WHERE e.salary > 70000; -- WHERE runs at Step 3
OUTPUT: INNER JOIN result — only matching rows from both tables
| name | dept_name | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Bob | Engineering | 82000 |
| Carol | Marketing | 74000 |
| Hank | Marketing | 71000 |
5. Step 3 — WHERE: Filtering Individual Rows
WHERE is applied after FROM and JOIN but before GROUP BY. It filters individual rows from the combined dataset. Because GROUP BY has not yet run, aggregate functions (SUM, AVG, COUNT, etc.) are not allowed in WHERE — the groups do not exist yet.
Because SELECT has also not yet run, column aliases defined in SELECT are not visible in WHERE. This is one of the most common mistakes SQL beginners make.
-- CORRECT: filter on actual column, not aliasSELECT name, salary, salary * 1.1 AS projected_salaryFROM employeesWHERE salary > 70000; -- 'salary' exists; 'projected_salary' does NOT yet-- WRONG: will raise an error — alias not visible in WHERE-- WHERE projected_salary > 77000; -- ERROR: column not found-- WORKAROUND: use a subquery or CTESELECT * FROM ( SELECT name, salary, salary * 1.1 AS projected_salary FROM employees) AS subWHERE projected_salary > 77000;
OUTPUT: WHERE salary > 70000 — filters BEFORE grouping
| name | department | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Bob | Engineering | 82000 |
| Carol | Marketing | 74000 |
| Hank | Marketing | 71000 |
Never use aggregate functions in WHERE. They belong in HAVING. Example: WHERE AVG(salary) > 80000 is invalid — use HAVING AVG(salary) > 80000 instead.
6. Step 4 — GROUP BY: Collapsing Rows into Groups
After WHERE has filtered individual rows, GROUP BY collapses those rows into groups. Each unique combination of the GROUP BY columns becomes one row in the working dataset. This is when aggregates are computed — the multiple input rows per group become a single summary row.
Once GROUP BY runs, the working dataset only has one row per group. All subsequent clauses (HAVING, SELECT, ORDER BY) operate on these grouped rows, not on original individual rows.
SELECT department, COUNT(*) AS headcount, AVG(salary) AS avg_salary, MAX(salary) AS max_salary, MIN(salary) AS min_salaryFROM employeesWHERE status = 'Active' -- Step 3: filter rows firstGROUP BY department -- Step 4: collapse to one row per deptORDER BY avg_salary DESC; -- Step 8: sort the grouped result
OUTPUT: GROUP BY department — one summary row per department
| department | headcount | avg_salary | max_salary | min_salary |
| Engineering | 3 | 89333.33 | 95000 | 82000 |
| Marketing | 3 | 71000.00 | 74000 | 68000 |
| HR | 2 | 59500.00 | 61000 | 58000 |
Non-aggregated columns in SELECT must appear in GROUP BY. Otherwise the database doesn’t know which row’s value to show for each group.
7. Step 5 — HAVING: Filtering Groups
HAVING filters the groups produced by GROUP BY — not individual rows. Because HAVING runs after GROUP BY and after aggregates are computed, aggregate functions are allowed here. This is the key difference from WHERE.
| Clause | Runs At Step | Filters | Aggregates Allowed? | Column Alias Visible? |
| WHERE | 3 — before GROUP BY | Individual rows | No | No |
| HAVING | 5 — after GROUP BY | Groups (aggregated rows) | Yes | No |
SELECT department, COUNT(*) AS headcount, AVG(salary) AS avg_salaryFROM employeesWHERE status = 'Active' -- Step 3: filter individual rowsGROUP BY department -- Step 4: groupHAVING AVG(salary) > 65000 -- Step 5: filter groups by aggregateORDER BY avg_salary DESC;
OUTPUT: HAVING AVG(salary) > 65000 — only departments meeting the threshold
| department | headcount | avg_salary |
| Engineering | 3 | 89333.33 |
| Marketing | 3 | 71000.00 |
HR (avg=59500) is excluded by HAVING. It survived WHERE because its individual employees are Active, but the group-level average fails the threshold.
8. Step 6 — SELECT: Evaluating Expressions & Defining Aliases
SELECT runs sixth — after the data has been sourced, joined, filtered, grouped, and having-filtered. This is when column expressions are evaluated, aliases are defined, and window functions are computed. From this step onward, SELECT aliases are visible.
This explains why aliases defined in SELECT cannot be used in WHERE, GROUP BY, or HAVING — those clauses run before SELECT. They CAN be used in ORDER BY and LIMIT, which run after SELECT.
SELECT name, salary, salary * 1.15 AS with_bonus, -- expression CASE WHEN salary > 80000 THEN 'High' ELSE 'Standard' END AS pay_band, -- CASE RANK() OVER (ORDER BY salary DESC) AS salary_rank, -- window function COUNT(*) OVER () AS total_employees -- window aggFROM employeesWHERE status = 'Active'ORDER BY salary_rank; -- ORDER BY (Step 8) can use SELECT aliases
OUTPUT: SELECT evaluates all expressions and window functions at Step 6
| name | salary | with_bonus | pay_band | salary_rank | total_employees |
| Alice | 95000 | 109250.00 | High | 1 | 6 |
| Eve | 91000 | 104650.00 | High | 2 | 6 |
| Bob | 82000 | 94300.00 | High | 3 | 6 |
| Carol | 74000 | 85100.00 | Standard | 4 | 6 |
| Hank | 71000 | 81650.00 | Standard | 5 | 6 |
| Frank | 61000 | 70150.00 | Standard | 6 | 6 |
9. Step 7 — DISTINCT: Removing Duplicate Rows
DISTINCT is applied after SELECT evaluates all expressions. It removes duplicate rows from the SELECT output. Because it runs after SELECT, it compares the final computed values, not the raw column values.
-- Find unique department and pay_band combinationsSELECT DISTINCT department, CASE WHEN salary > 80000 THEN 'High' ELSE 'Standard' END AS pay_bandFROM employeesORDER BY department;
OUTPUT: DISTINCT runs after SELECT evaluates the CASE expression
| department | pay_band |
| Engineering | High |
| HR | Standard |
| Marketing | Standard |
Without DISTINCT, Engineering would appear three times (once per employee). DISTINCT deduplicates the (department, pay_band) pairs.
10. Step 8 — ORDER BY: Sorting the Final Result
ORDER BY sorts the complete result set produced by all previous steps. Because it runs after SELECT, it can reference SELECT aliases by name — unlike WHERE, GROUP BY, and HAVING. It can also reference column positions (ORDER BY 2 means the second column in SELECT).
SELECT name, salary, department, salary * 1.10 AS projected_salary -- defined in Step 6FROM employeesORDER BY projected_salary DESC, -- alias visible in ORDER BY (Step 8) department ASC;
OUTPUT: ORDER BY uses SELECT alias — valid because ORDER BY runs after SELECT
| name | salary | department | projected_salary |
| Alice | 95000 | Engineering | 104500.00 |
| Eve | 91000 | Engineering | 100100.00 |
| Bob | 82000 | Engineering | 90200.00 |
| Carol | 74000 | Marketing | 81400.00 |
| Hank | 71000 | Marketing | 78100.00 |
| David | 68000 | Marketing | 74800.00 |
| Frank | 61000 | HR | 67100.00 |
| Grace | 58000 | HR | 63800.00 |
11. Step 9 — LIMIT / TOP / FETCH FIRST: Restricting Rows
LIMIT (MySQL, PostgreSQL), TOP (SQL Server), and FETCH FIRST n ROWS ONLY (SQL Standard / Oracle) are applied absolutely last — after every other clause has processed. They slice the sorted result to return only the requested number of rows.
-- MySQL / PostgreSQLSELECT name, department, salaryFROM employeesWHERE status = 'Active'ORDER BY salary DESCLIMIT 3; -- Applied last: take top 3 after ORDER BY-- SQL ServerSELECT TOP 3 name, department, salaryFROM employeesWHERE status = 'Active'ORDER BY salary DESC;-- SQL Standard / Oracle 12c+SELECT name, department, salaryFROM employeesWHERE status = 'Active'ORDER BY salary DESCFETCH FIRST 3 ROWS ONLY;
OUTPUT: LIMIT 3 — only the top 3 rows after all steps are complete
| name | department | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Bob | Engineering | 82000 |
LIMIT / TOP applied without ORDER BY returns an arbitrary set of rows — the database has no guaranteed row order without ORDER BY. Always pair LIMIT with ORDER BY in production queries.
12. Alias Visibility Rules — A Critical Reference
The most common SQL errors caused by execution order relate to alias visibility. Here is the complete reference:
| Clause | Can Reference SELECT Alias? | Reason |
| FROM | No | FROM runs before SELECT — alias doesn’t exist yet |
| JOIN / ON | No | JOIN runs before SELECT — alias doesn’t exist yet |
| WHERE | No | WHERE runs before SELECT — alias doesn’t exist yet |
| GROUP BY | No | GROUP BY runs before SELECT — alias doesn’t exist yet |
| HAVING | No | HAVING runs before SELECT — alias doesn’t exist yet |
| SELECT | No (same step) | Aliases are defined here, not usable in same SELECT list |
| DISTINCT | Yes | DISTINCT runs after SELECT — alias is defined |
| ORDER BY | Yes | ORDER BY runs after SELECT — alias is visible |
| LIMIT | Yes (position) | LIMIT uses row position, not column references |
Workaround Patterns for Alias Reuse
-- Problem: cannot use alias in WHERE or GROUP BYSELECT salary * 1.1 AS projected FROM employees WHERE projected > 80000; -- ERROR-- Solution 1: Wrap in a subquerySELECT * FROM ( SELECT name, salary, salary * 1.1 AS projected FROM employees) subWHERE projected > 80000;-- Solution 2: Use a CTE (cleaner)WITH base AS ( SELECT name, salary, salary * 1.1 AS projected FROM employees)SELECT * FROM base WHERE projected > 80000;-- Solution 3: Repeat the expression (not ideal but simple)SELECT name, salary, salary * 1.1 AS projectedFROM employeesWHERE salary * 1.1 > 80000; -- repeat the expression
13. Full Query Walkthrough — All 9 Steps
Let us trace a complex query through every single step to see exactly how the database processes it.
The Query
SELECT d.dept_name, COUNT(e.emp_id) AS headcount, AVG(e.salary) AS avg_salary, MAX(e.salary) AS top_salaryFROM employees eINNER JOIN departments d ON e.dept_id = d.idWHERE e.hire_year >= 2019GROUP BY d.dept_nameHAVING COUNT(e.emp_id) >= 2ORDER BY avg_salary DESCLIMIT 3;
Step-by-Step Trace
| Step | Clause | What the DB Does | Rows After This Step |
| 1 | FROM employees e | Load entire employees table into working set | 8 rows (all employees) |
| 2 | INNER JOIN departments d ON e.dept_id = d.id | Match each employee to their department row | 8 rows (all have dept — assuming all match) |
| 3 | WHERE e.hire_year >= 2019 | Discard employees hired before 2019 | 6 rows (hired 2019 or later) |
| 4 | GROUP BY d.dept_name | Collapse 6 rows into 3 groups (one per dept) | 3 groups: Engineering(3), HR(1), Marketing(2) |
| 5 | HAVING COUNT(e.emp_id) >= 2 | Remove groups with fewer than 2 employees | 2 groups: Engineering(3), Marketing(2) — HR removed |
| 6 | SELECT d.dept_name, COUNT, AVG, MAX | Compute aggregate expressions; define aliases | 2 rows: Engineering and Marketing with computed columns |
| 7 | (No DISTINCT) | Skipped — no DISTINCT in this query | 2 rows (unchanged) |
| 8 | ORDER BY avg_salary DESC | Sort the 2 rows by avg_salary descending | 2 rows: Engineering first (higher avg), then Marketing |
| 9 | LIMIT 3 | Return at most 3 rows — already have only 2 | 2 rows returned (both groups) |
Final Output
OUTPUT: Complete query result after all 9 execution steps
| dept_name | headcount | avg_salary | top_salary |
| Engineering | 3 | 89333.33 | 95000 |
| Marketing | 2 | 72500.00 | 74000 |
14. CTEs and Subqueries in the Execution Order
Common Table Expressions (CTEs) and subqueries extend the execution order model. A subquery in the FROM clause is materialised first as a virtual table, before the outer query’s FROM step begins.
-- CTE is executed first, before the outer query's FROMWITH dept_stats AS ( SELECT dept_id, AVG(salary) AS avg_salary, -- Step 6 of the INNER query COUNT(*) AS headcount FROM employees GROUP BY dept_id -- Step 4 of the INNER query)-- Outer query execution begins hereSELECT e.name, e.salary, ds.avg_salary, e.salary - ds.avg_salary AS diff_from_avgFROM employees e -- Step 1 of OUTER queryJOIN dept_stats ds ON e.dept_id = ds.dept_id -- Step 2WHERE e.salary > ds.avg_salary -- Step 3ORDER BY diff_from_avg DESC; -- Step 8
OUTPUT: Employees earning above their department average
| name | salary | avg_salary | diff_from_avg |
| Alice | 95000 | 89333.33 | +5666.67 |
| Eve | 91000 | 89333.33 | +1666.67 |
| Carol | 74000 | 71000.00 | +3000.00 |
| Frank | 61000 | 59500.00 | +1500.00 |
15. Common Mistakes Caused by Execution Order
| Mistake | Error or Wrong Result | Correct Approach |
| WHERE AVG(salary) > 80000 | ERROR: aggregates not allowed in WHERE | Use HAVING AVG(salary) > 80000 instead |
| WHERE alias_col > 100 (SELECT alias) | ERROR: alias not visible in WHERE | Wrap in a subquery/CTE; repeat the expression |
| GROUP BY alias_col | ERROR in most DBs; MySQL may accept it | Use the original expression in GROUP BY |
| SELECT * with GROUP BY | ERROR: non-aggregated columns must be in GROUP BY | List only grouped columns or use aggregate functions |
| ORDER BY without LIMIT | Returns all rows sorted — usually fine | Add LIMIT when you only need top N rows |
| LIMIT without ORDER BY | Returns arbitrary rows — not deterministic | Always pair LIMIT with ORDER BY |
| HAVING without GROUP BY | Treats entire table as one group | Use WHERE if you don’t need GROUP BY |
16. Quick Reference — Order of Execution Cheat Sheet
| Execution Order | Clause | Key Point |
| 1 | FROM | First to execute — defines the source dataset |
| 2 | JOIN / ON | Applies join predicates — LEFT/INNER/FULL affect rows here |
| 3 | WHERE | Filters rows — no aggregates, no SELECT aliases |
| 4 | GROUP BY | Creates groups — result is one row per group value |
| 5 | HAVING | Filters groups — aggregates ARE allowed |
| 6 | SELECT | Evaluates expressions, aliases, window functions |
| 7 | DISTINCT | Deduplicates after SELECT evaluates expressions |
| 8 | ORDER BY | Sorts result — SELECT aliases are visible here |
| 9 | LIMIT | Cuts result to N rows — applied last |
| Writing Order vs Execution Order | |
| SELECT (written 1st) | Executed 6th |
| FROM (written 2nd) | Executed 1st |
| JOIN (written 3rd) | Executed 2nd |
| WHERE (written 4th) | Executed 3rd |
| GROUP BY (written 5th) | Executed 4th |
| HAVING (written 6th) | Executed 5th |
| ORDER BY (written 7th) | Executed 8th |
| LIMIT (written 8th) | Executed 9th |
17. Conclusion
The logical order of SQL execution is one of those foundational concepts that, once understood, makes everything else click into place. It explains why aliases work in ORDER BY but not in WHERE, why HAVING can filter on aggregates but WHERE cannot, and why LIMIT always takes the last slice of an already-sorted result.
Memorise the nine steps: FROM, JOIN, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, LIMIT. Notice that SELECT — which you always write first — is only the sixth step. The database has already done the heavy lifting of sourcing, joining, filtering, and grouping by the time it decides which columns to project.
Apply this knowledge when debugging unexpected errors or wrong results: trace which step is failing, check whether you are using a construct (alias, aggregate) in a clause that runs before it is available, and use a CTE or subquery to promote it to the right execution level.
Happy Querying!
Discover more from DataSangyan
Subscribe to get the latest posts sent to your email.