SQL Execution Order: 9 Steps Every Developer Must Know

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.

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.

Flowchart illustrating the logical order of execution for SQL queries, detailing nine steps including FROM, JOIN, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, and LIMIT, along with their execution order and critical rules.

Figure 1: SQL Query — Logical Order of Execution (9 steps with alias visibility & writing order comparison)

Quick Summary

StepClauseWhat HappensSELECT Alias Visible?
1FROMLoad source tables; compute Cartesian product of all listed tablesNo
2JOIN / ONApply join conditions to filter the Cartesian productNo
3WHEREFilter individual rows — no aggregates, no SELECT aliasesNo
4GROUP BYCollapse rows matching the same group key into one row per groupNo
5HAVINGFilter groups — aggregate functions ARE available hereNo
6SELECTEvaluate expressions, compute aggregates, define column aliasesYes (defined here)
7DISTINCTRemove duplicate rows from the SELECT resultYes
8ORDER BYSort the result — SELECT aliases are visibleYes
9LIMIT/TOPReturn only the first N rows of the sorted resultYes

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 first
SELECT *
FROM employees e -- Step 1a: load employees
JOIN 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.

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 TypeRows Included
INNER JOINOnly rows that match the ON condition in BOTH tables
LEFT JOINAll rows from the left table; NULLs for unmatched right rows
RIGHT JOINAll rows from the right table; NULLs for unmatched left rows
FULL OUTER JOINAll rows from both tables; NULLs where no match
CROSS JOINCartesian 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, budget
SELECT e.name, d.dept_name, e.salary
FROM employees e
JOIN departments d ON e.dept_id = d.id -- ON runs at Step 2
WHERE e.salary > 70000; -- WHERE runs at Step 3

 

OUTPUT: INNER JOIN result — only matching rows from both tables

namedept_namesalary
AliceEngineering95000
EveEngineering91000
BobEngineering82000
CarolMarketing74000
HankMarketing71000

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 alias
SELECT name, salary, salary * 1.1 AS projected_salary
FROM employees
WHERE 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 CTE
SELECT * FROM (
SELECT name, salary, salary * 1.1 AS projected_salary FROM employees
) AS sub
WHERE projected_salary > 77000;

 

OUTPUT: WHERE salary > 70000 — filters BEFORE grouping

namedepartmentsalary
AliceEngineering95000
EveEngineering91000
BobEngineering82000
CarolMarketing74000
HankMarketing71000

Never use aggregate functions in WHERE. They belong in HAVING. Example: WHERE AVG(salary) > 80000 is invalid — use HAVING AVG(salary) > 80000 instead.

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_salary
FROM employees
WHERE status = 'Active' -- Step 3: filter rows first
GROUP BY department -- Step 4: collapse to one row per dept
ORDER BY avg_salary DESC; -- Step 8: sort the grouped result

 

OUTPUT: GROUP BY department — one summary row per department

departmentheadcountavg_salarymax_salarymin_salary
Engineering389333.339500082000
Marketing371000.007400068000
HR259500.006100058000

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.

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.

ClauseRuns At StepFiltersAggregates Allowed?Column Alias Visible?
WHERE3 — before GROUP BYIndividual rowsNoNo
HAVING5 — after GROUP BYGroups (aggregated rows)YesNo

SELECT department,
COUNT(*) AS headcount,
AVG(salary) AS avg_salary
FROM employees
WHERE status = 'Active' -- Step 3: filter individual rows
GROUP BY department -- Step 4: group
HAVING AVG(salary) > 65000 -- Step 5: filter groups by aggregate
ORDER BY avg_salary DESC;

 

OUTPUT: HAVING AVG(salary) > 65000 — only departments meeting the threshold

departmentheadcountavg_salary
Engineering389333.33
Marketing371000.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.

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 agg
FROM employees
WHERE 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

namesalarywith_bonuspay_bandsalary_ranktotal_employees
Alice95000109250.00High16
Eve91000104650.00High26
Bob8200094300.00High36
Carol7400085100.00Standard46
Hank7100081650.00Standard56
Frank6100070150.00Standard66

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 combinations
SELECT DISTINCT
department,
CASE WHEN salary > 80000 THEN 'High'
ELSE 'Standard' END AS pay_band
FROM employees
ORDER BY department;

 

OUTPUT: DISTINCT runs after SELECT evaluates the CASE expression

departmentpay_band
EngineeringHigh
HRStandard
MarketingStandard

Without DISTINCT, Engineering would appear three times (once per employee). DISTINCT deduplicates the (department, pay_band) pairs.

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 6
FROM employees
ORDER 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

namesalarydepartmentprojected_salary
Alice95000Engineering104500.00
Eve91000Engineering100100.00
Bob82000Engineering90200.00
Carol74000Marketing81400.00
Hank71000Marketing78100.00
David68000Marketing74800.00
Frank61000HR67100.00
Grace58000HR63800.00

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 / PostgreSQL
SELECT name, department, salary
FROM employees
WHERE status = 'Active'
ORDER BY salary DESC
LIMIT 3; -- Applied last: take top 3 after ORDER BY
-- SQL Server
SELECT TOP 3 name, department, salary
FROM employees
WHERE status = 'Active'
ORDER BY salary DESC;
-- SQL Standard / Oracle 12c+
SELECT name, department, salary
FROM employees
WHERE status = 'Active'
ORDER BY salary DESC
FETCH FIRST 3 ROWS ONLY;

 

OUTPUT: LIMIT 3 — only the top 3 rows after all steps are complete

namedepartmentsalary
AliceEngineering95000
EveEngineering91000
BobEngineering82000

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.

The most common SQL errors caused by execution order relate to alias visibility. Here is the complete reference:

ClauseCan Reference SELECT Alias?Reason
FROMNoFROM runs before SELECT — alias doesn’t exist yet
JOIN / ONNoJOIN runs before SELECT — alias doesn’t exist yet
WHERENoWHERE runs before SELECT — alias doesn’t exist yet
GROUP BYNoGROUP BY runs before SELECT — alias doesn’t exist yet
HAVINGNoHAVING runs before SELECT — alias doesn’t exist yet
SELECTNo (same step)Aliases are defined here, not usable in same SELECT list
DISTINCTYesDISTINCT runs after SELECT — alias is defined
ORDER BYYesORDER BY runs after SELECT — alias is visible
LIMITYes (position)LIMIT uses row position, not column references

-- Problem: cannot use alias in WHERE or GROUP BY
SELECT salary * 1.1 AS projected FROM employees WHERE projected > 80000; -- ERROR
-- Solution 1: Wrap in a subquery
SELECT * FROM (
SELECT name, salary, salary * 1.1 AS projected FROM employees
) sub
WHERE 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 projected
FROM employees
WHERE salary * 1.1 > 80000; -- repeat the expression

Let us trace a complex query through every single step to see exactly how the database processes it.

SELECT
d.dept_name,
COUNT(e.emp_id) AS headcount,
AVG(e.salary) AS avg_salary,
MAX(e.salary) AS top_salary
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id
WHERE e.hire_year >= 2019
GROUP BY d.dept_name
HAVING COUNT(e.emp_id) >= 2
ORDER BY avg_salary DESC
LIMIT 3;

StepClauseWhat the DB DoesRows After This Step
1FROM employees eLoad entire employees table into working set8 rows (all employees)
2INNER JOIN departments d ON e.dept_id = d.idMatch each employee to their department row8 rows (all have dept — assuming all match)
3WHERE e.hire_year >= 2019Discard employees hired before 20196 rows (hired 2019 or later)
4GROUP BY d.dept_nameCollapse 6 rows into 3 groups (one per dept)3 groups: Engineering(3), HR(1), Marketing(2)
5HAVING COUNT(e.emp_id) >= 2Remove groups with fewer than 2 employees2 groups: Engineering(3), Marketing(2) — HR removed
6SELECT d.dept_name, COUNT, AVG, MAXCompute aggregate expressions; define aliases2 rows: Engineering and Marketing with computed columns
7(No DISTINCT)Skipped — no DISTINCT in this query2 rows (unchanged)
8ORDER BY avg_salary DESCSort the 2 rows by avg_salary descending2 rows: Engineering first (higher avg), then Marketing
9LIMIT 3Return at most 3 rows — already have only 22 rows returned (both groups)

 

OUTPUT: Complete query result after all 9 execution steps

dept_nameheadcountavg_salarytop_salary
Engineering389333.3395000
Marketing272500.0074000

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 FROM
WITH 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 here
SELECT e.name, e.salary, ds.avg_salary,
e.salary - ds.avg_salary AS diff_from_avg
FROM employees e -- Step 1 of OUTER query
JOIN dept_stats ds ON e.dept_id = ds.dept_id -- Step 2
WHERE e.salary > ds.avg_salary -- Step 3
ORDER BY diff_from_avg DESC; -- Step 8

 

OUTPUT: Employees earning above their department average

namesalaryavg_salarydiff_from_avg
Alice9500089333.33+5666.67
Eve9100089333.33+1666.67
Carol7400071000.00+3000.00
Frank6100059500.00+1500.00

MistakeError or Wrong ResultCorrect Approach
WHERE AVG(salary) > 80000ERROR: aggregates not allowed in WHEREUse HAVING AVG(salary) > 80000 instead
WHERE alias_col > 100 (SELECT alias)ERROR: alias not visible in WHEREWrap in a subquery/CTE; repeat the expression
GROUP BY alias_colERROR in most DBs; MySQL may accept itUse the original expression in GROUP BY
SELECT * with GROUP BYERROR: non-aggregated columns must be in GROUP BYList only grouped columns or use aggregate functions
ORDER BY without LIMITReturns all rows sorted — usually fineAdd LIMIT when you only need top N rows
LIMIT without ORDER BYReturns arbitrary rows — not deterministicAlways pair LIMIT with ORDER BY
HAVING without GROUP BYTreats entire table as one groupUse WHERE if you don’t need GROUP BY

Execution OrderClauseKey Point
1FROMFirst to execute — defines the source dataset
2JOIN / ONApplies join predicates — LEFT/INNER/FULL affect rows here
3WHEREFilters rows — no aggregates, no SELECT aliases
4GROUP BYCreates groups — result is one row per group value
5HAVINGFilters groups — aggregates ARE allowed
6SELECTEvaluates expressions, aliases, window functions
7DISTINCTDeduplicates after SELECT evaluates expressions
8ORDER BYSorts result — SELECT aliases are visible here
9LIMITCuts 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

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.


Discover more from DataSangyan

Subscribe to get the latest posts sent to your email.

Leave a Reply