Skip to content
Reference / SQL Order of Execution
Reference

SQL Order of Execution

You write SELECT first, but the database gets to it fifth. The logical order of the clauses decides what each one can see.

A query is written in one order and processed in another. You write SELECT first, but the database starts with FROM, filters with WHERE, groups, filters the groups, and only then works out the SELECT list, sorts it, and cuts it to size. Each clause sees only what the clauses before it have produced. That one rule explains why WHERE can't filter on a count(*), why ORDER BY can use a column alias, and why LIMIT without ORDER BY returns arbitrary rows.

The order

Step Clause What it does
1 FROM, JOIN ... ON Reads the tables and matches joined rows.
2 WHERE Keeps the rows that match. No groups exist yet.
3 GROUP BY Collapses the remaining rows into one row per group.
4 HAVING Keeps the groups that match. Aggregates are available now.
5 Window functions, QUALIFY Computes over (...) values across the surviving rows, then QUALIFY filters on them.
6 SELECT, DISTINCT Builds the output columns, names their aliases, and removes duplicate rows.
7 ORDER BY Sorts the result. Aliases from step 6 are visible here.
8 LIMIT, OFFSET Skips and keeps rows from the sorted result.

This is the logical order: the order the answer is defined in. The database is free to do the work in a different physical order, as long as the answer comes out the same (see below).

One query, step by step

Which states have the most shipped orders in the Garden Shop data? This query uses every main clause:

Shipped orders by state
select c.state, count(*) as shipped_orders
from orders as o
join customers as c on c.customer_id = o.customer_id
where o.status = 'shipped'
group by c.state
having count(*) >= 2
order by shipped_orders desc, c.state
limit 3

Here is what each step hands to the next:

Step Rows out What happened
FROM and JOIN 24 Every order, with its customer's state attached.
WHERE 17 Pending, processing, and cancelled orders are gone.
GROUP BY 7 One row per state, each with its count.
HAVING 5 Ohio and Washington, with one shipped order each, drop out.
SELECT 5 The count gets its name, shipped_orders.
ORDER BY 5 PA (5), CA (4), then FL, OR, and TX tied on 2, ordered by state.
LIMIT 3 PA, CA, and FL.

The tie-breaker matters. Three states tie on 2 and only one fits under limit 3, so without c.state in the ORDER BY the third row could be any of them.

What the order explains

WHERE can't see aggregates

WHERE runs at step 2, before any groups exist, so there is no count(*) for it to test. DuckDB says WHERE clause cannot contain aggregates! Filter groups with HAVING, which runs after grouping.

Wrong: an aggregate in WHERE
select state, count(*) as customers
from customers
where count(*) >= 3
group by state
Right: filter the groups with HAVING
select state, count(*) as customers
from customers
group by state
having count(*) >= 3
order by state

That returns CA, OH, and PA. More on this error: aggregate functions are not allowed in WHERE.

WHERE and HAVING can't see window functions

Window functions are computed at step 5, after HAVING, so neither filter can use one. Filter on the result with QUALIFY, which runs right after the window functions, or wrap the query in a CTE and filter outside it.

Right: QUALIFY runs after the window
select product_name, price,
       row_number() over (order by price desc, product_id) as price_rank
from products
qualify price_rank <= 3

See window functions are not allowed in WHERE for the CTE version, which runs on engines without QUALIFY.

Window functions only see rows WHERE kept

Because WHERE runs first, a window over "all rows" means all rows that survived the filter. Garden Shop has 24 products, but this window counts 5:

The window counts filtered rows
select product_name, price,
       count(*) over () as products_counted
from products
where price > 20
order by price desc

To compare each row with the whole table, compute the window in a CTE first and filter afterwards.

ORDER BY can use aliases and unselected columns

ORDER BY runs after SELECT, so every engine lets it sort by an alias such as shipped_orders. It can also sort by a column the SELECT list leaves out, because the underlying row is still there:

Sort by a column you don't return
select product_name
from products
order by price desc, product_id
limit 3

LIMIT comes last

LIMIT keeps the first rows of the sorted result. With no ORDER BY there is no "first", and the engine returns whichever rows it reaches first. See LIMIT without ORDER BY gives unpredictable rows.

Aliases in WHERE: DuckDB bends the rule

By the order above, a SELECT alias doesn't exist yet when WHERE runs. PostgreSQL, MySQL, and SQL Server enforce that. DuckDB, which runs this site's queries, and SQLite look the alias up for you, so this runs here:

Runs in DuckDB, fails in PostgreSQL
select product_name, price - cost as margin
from products
where margin > 10
order by margin desc, product_name

The convenience stops where the order would break. An alias for an aggregate (count(*) as customers) or a window function still can't appear in WHERE, and DuckDB raises the same errors as above. Repeat the expression or use a CTE when the query has to run elsewhere; the column not found error page shows both fixes.

Logical order, not physical order

The logical order defines the answer, not the work. Ask DuckDB for the plan of the shipped-orders query and the filter on status isn't a step after the join: it sits inside the scan of orders, as Filters: status='shipped', so only shipped orders ever reach the join. The result is the same; the join just has fewer rows to match.

Where the filter really runs
explain
select c.state, count(*)
from orders as o
join customers as c on c.customer_id = o.customer_id
where o.status = 'shipped'
group by c.state

So use the logical order to reason about what a query means, and EXPLAIN to see what the engine does to answer it.

What can go wrong

  • An alias that reuses a column name. In select price * 0 as price ... where price > 20, DuckDB's WHERE reads the real price column and keeps the 5 products priced over 20, while ORDER BY price sorts by the alias. The same word means two things in one query. Give a calculated column a new name.
  • A WHERE filter on the right side of a LEFT JOIN. WHERE runs after the join has added its NULL rows, so a condition on the right table throws them away again. Put that condition in ON. See why a LEFT JOIN turns into an INNER JOIN.
  • DISTINCT with ORDER BY on a column you didn't select. select distinct category_id from products order by price runs in DuckDB, but each category has products at several prices, so the order has no clear meaning. PostgreSQL rejects it: for SELECT DISTINCT, ORDER BY expressions must appear in select list. Sort by an aggregate instead, such as group by category_id order by min(price).
  • Code that works here and fails in production. An alias in WHERE, or QUALIFY, is fine in DuckDB and an error in PostgreSQL. Check the table below before you move a query.

Other databases

Every engine uses the same logical order. They differ in how far they let a SELECT alias reach back into earlier clauses, and in whether QUALIFY exists.

Engine Alias in WHERE Alias in GROUP BY Alias in HAVING QUALIFY
DuckDB Yes Yes Yes Yes
SQLite Yes Yes Yes No
PostgreSQL No Yes No No
MySQL No Yes Yes No
SQL Server No No No No

All five accept an alias in ORDER BY. Snowflake, BigQuery, and Databricks also support QUALIFY. The portable habit is the same everywhere: filter on the full expression, or name it in a CTE and filter outside.

See also

SELECT syntax covers the clauses one by one, and the query basics cheatsheet has the order on one card.