Short answer

  • SQL is written SELECT first but evaluated FROM first. The logical order for the common clauses is FROM, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, then LIMIT. Microsoft Learn publishes a fuller SQL Server sequence that also places ON and JOIN after FROM, and TOP after ORDER BY.
  • Because SELECT runs after WHERE, a column alias created in SELECT does not exist yet when WHERE runs. That is why WHERE total_spent > 200 fails when total_spent is a SELECT alias, while ORDER BY total_spent works.
  • WHERE filters individual rows before grouping. HAVING filters whole groups after grouping. Aggregate functions such as SUM() belong in HAVING or SELECT, never in WHERE.
  • The logical order is a model of what the result must be, not a description of how the database physically runs the query. The query optimizer may reorder the physical work freely, as long as the result matches the logical order. Microsoft Learn and the SQLite documentation both state this plainly.
  • Every code example in this article was run in SQLite through Python sqlite3 on 12 November 2026, and every result table shown is the real printed output of that run, including the error messages.

What “Order of Operations” Means in SQL

In most programming languages, code runs in the order it is written, from the first line to the last. SQL does not work that way. A query is written with SELECT first, but the database evaluates the clauses in a different order, starting with FROM. The phrase “SQL order of operations” names that evaluation order, and it explains a large share of the errors beginners meet: aliases that work in one clause and fail in another, aggregates that are rejected in WHERE, and filters that seem to run at the wrong time.

The order matters because each clause receives the output of the clause before it. FROM builds the working set of rows. WHERE removes rows from that set. GROUP BY collapses the surviving rows into groups. HAVING removes whole groups. Only then does SELECT compute the output columns and create the aliases. DISTINCT removes duplicate output rows. ORDER BY sorts what is left. LIMIT cuts the sorted result down to the number of rows requested. A clause can only use what earlier clauses have already produced. Nothing later in the sequence is available to anything earlier.

There are two orders to keep apart, and confusing them is the root of most mistakes. The written order is the order the clauses must appear in the statement: SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT. The logical order is the order the database conceptually evaluates them: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. The written order is a rule of syntax. The logical order is a rule of meaning. This article teaches the logical order, because that is the order that decides whether a query runs and what it returns.

There is also a third order, the physical execution order, which is the order the database engine actually does the work: which index it reads, whether it sorts before or after filtering, and in what order it scans the tables. That order is chosen by the query optimizer, and it is not visible in the query text. The optimizer is free to rearrange physical work as long as the final result is the same as the logical order requires. Microsoft Learn says exactly this about SQL Server: the query processor determines the actual physical execution of the statement, and the order might vary from the logical list. The SQLite documentation says the same about its own step-by-step description: it is purely illustrative, and in practice neither SQLite nor any other SQL engine is required to follow that or any other specific process. So when this article says a clause runs before another clause, it means logically, in the model that defines the result. The physical work may happen differently under the surface, and that is normal and safe.

Written Order vs Logical Order

The same query, written one way and evaluated another, is the whole subject in miniature. Take a query that totals orders by customer and keeps only the big spenders:

SELECT customer, SUM(amount) AS total_spent
FROM orders
WHERE region = 'North'
GROUP BY customer
HAVING SUM(amount) > 200
ORDER BY total_spent DESC
LIMIT 3;

Written order, top to bottom, is SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT. Logical order is the reverse of what the eye expects:

Logical stepClauseWhat it does
1FROMGathers the source rows
2WHERERemoves individual rows that fail the test
3GROUP BYCollapses surviving rows into groups
4HAVINGRemoves whole groups that fail the test
5SELECTComputes the output columns and creates aliases
6DISTINCTRemoves duplicate output rows
7ORDER BYSorts the output rows
8LIMITKeeps only the first rows of the sorted result

If a clause is missing from the query, its step is skipped and the order of the rest does not change. A query with no GROUP BY simply has no grouping step, and HAVING without GROUP BY treats the whole table as a single group, as the worked example in section 7 shows. The steps that are present always run in this relative order.

The full vendor sequences add steps around this core. Microsoft Learn lists the SQL Server logical processing order as FROM, ON, JOIN, WHERE, GROUP BY, WITH CUBE or WITH ROLLUP, HAVING, QUALIFY (in Fabric Data Warehouse only), SELECT, DISTINCT, ORDER BY, TOP. PostgreSQL documents its general processing as WITH, FROM, WHERE, GROUP BY and HAVING, SELECT output expressions, DISTINCT, UNION, INTERSECT and EXCEPT, ORDER BY, LIMIT or FETCH FIRST, then the locking clause. The core six clauses sit in the same relative order in every one of these lists. The differences are the extra steps each system adds, not a disagreement about the core.

The Logical Order Step by Step

Each step below takes the output of the previous step as its input, and hands its own output to the next step. Think of the data as a table that shrinks, regroups, and reshapes as it passes down the line.

Step 1: FROM and JOIN

FROM runs first. It decides which tables the query reads and combines them into the working set of rows. When the FROM clause joins two tables, the join condition in ON is evaluated as part of this step, before WHERE ever sees a row. Microsoft Learn lists ON and JOIN as steps 2 and 3, immediately after FROM and before WHERE, which is the formal version of the same point: the joined table is built first, and everything else works on the result.

This placement explains the most common join mistake. In a LEFT JOIN, the ON condition decides which rows match, and unmatched left rows are kept with NULL values filled in on the right. WHERE runs after that, on the joined result, so a WHERE condition on a right-table column will remove those NULL rows and silently turn the outer join into an inner join. The SQLite documentation spells this out: in an outer join, the extra NULL rows are added after ON processing but before WHERE processing, so a constraint in the ON clause allows the all-NULL rows through, while the same constraint in WHERE excludes them because NULL compared with anything is not true. For the join types themselves and when each one is used, see our guide to inner join in SQL. A companion guide to left vs right join is in preparation in the same wave as this article, so confirm it is live before relying on the link at build time.

FROM is also where table aliases are created, and those aliases are available to every later clause. A table alias defined in FROM can be used in WHERE, GROUP BY, HAVING, SELECT, and ORDER BY, because FROM runs before all of them. Column aliases are different, and that difference is the subject of section 5.

Step 2: WHERE

WHERE filters individual rows. It tests each row of the working set against its condition and keeps only the rows where the condition is true. Rows where the condition is false or NULL are removed. The SQLite documentation states this directly: rows are excluded from the result if the WHERE clause evaluates to either false or NULL. That NULL detail matters whenever a column can be empty. WHERE discount > 0 removes rows where discount is NULL, not just rows where it is zero or negative, because NULL compared with zero is NULL, not true.

WHERE can only use columns that exist in the working set at this point, which means columns of the tables named in FROM, and expressions built from them. It cannot use a SELECT alias, because SELECT has not run yet. It cannot use an aggregate function, because no grouping has happened yet and there is no group to aggregate. Both restrictions are consequences of the order, not arbitrary rules, and both are covered with executed examples in sections 5 and 8.

WHERE can filter on a column that never appears in the output. The SELECT list decides what the reader sees, and WHERE decides which rows survive, and the two decisions are independent. For example, selecting only customer while filtering on region = 'South' returns three rows, Ava, Ben and Cid, without complaint. For the comparison operators WHERE uses most, including how equality and inequality tests treat NULL, see our guide to equal and not equal in SQL.

Step 3: GROUP BY

GROUP BY collapses the surviving rows into groups, one group per distinct combination of the grouping columns, and one output row per group. From this step onward, the query works with groups rather than individual rows. A column that is not in the GROUP BY list and is not inside an aggregate function has no single value within a group, so it cannot be selected directly. This is why SELECT customer, region, SUM(amount) FROM orders GROUP BY customer is an error in strict systems: a customer group can contain rows from more than one region, so region has no one value to show.

GROUP BY runs after WHERE, so the groups are formed only from the rows WHERE kept. Filtering before grouping and filtering after grouping are different operations with different results, and choosing the wrong one is one of the most common SQL mistakes. Section 7 works through the difference with real numbers. Our guide to what is GROUP BY in SQL covers grouping itself in depth, and it is in preparation in the same wave as this article, so confirm it is live before relying on the link at build time. For counting inside groups, including counting a count, see our guide to count of count in SQL.

PostgreSQL notes one extension worth knowing: output columns can be referenced by name or ordinal number in the GROUP BY clause. MySQL permits column aliases in GROUP BY, ORDER BY, and HAVING. SQLite accepts an alias in GROUP BY, as the executed example in section 5 confirms. These are extensions beyond standard SQL, and they vary by system, so a query that groups by an alias may run in one database and fail in another.

Step 4: HAVING

HAVING filters whole groups, the way WHERE filters individual rows. It runs after GROUP BY, so it can use aggregate functions: HAVING SUM(amount) > 200 keeps only the groups whose total exceeds 200. It can also use the grouping columns themselves. It cannot use a SELECT alias in standard SQL, for the same reason WHERE cannot: SELECT has not run yet. MySQL is an exception here, and it expressly permits aliases in HAVING, so a MySQL query that filters on an alias in HAVING will run there and fail elsewhere.

HAVING is often described as “WHERE for groups,” and that is a fair summary as long as the timing is kept in mind. WHERE removes rows before the groups are formed, so it changes the groups themselves. HAVING removes groups after they are formed, so it never changes what is inside a surviving group. Moving a condition from WHERE to HAVING, or the reverse, changes the answer. Section 7 shows a condition in each place and the different totals that result. Our guide to HAVING in SQL covers this clause on its own, including the cases where HAVING is used without GROUP BY.

Step 5: SELECT

SELECT runs fifth, after the rows have been gathered, filtered, grouped, and group-filtered. Only now are the output columns computed, expressions evaluated, and aliases created. This late position is the single most surprising fact in the whole order, because SELECT is written first and read first, yet evaluated near last.

Because SELECT runs here, a column alias created in the SELECT list exists only from this step onward. ORDER BY can use it, because ORDER BY runs after SELECT. WHERE, GROUP BY, and HAVING cannot use it in standard SQL, because they run before SELECT. Microsoft Learn states this directly: because the SELECT clause is step 8 in the SQL Server sequence, any column aliases or derived columns defined in that clause cannot be referenced by preceding clauses, but they can be referenced by subsequent clauses such as ORDER BY.

SELECT is also where computed columns are built. A CASE expression, a multiplication, a string concatenation, or a function call in the SELECT list is evaluated at this step, against the grouped or ungrouped rows that survived the earlier steps. For conditional expressions in the SELECT list, including CASE, see our guide to SQL queries with CASE.

Step 6: DISTINCT

DISTINCT runs after SELECT and removes duplicate rows from the output. It compares whole output rows, not individual columns, so SELECT DISTINCT region returns each region once, while SELECT DISTINCT region, customer returns each distinct pairing once. DISTINCT is part of the SELECT clause in the written statement, but logically it is a separate step that operates on the computed output.

DISTINCT runs before ORDER BY, which is why ORDER BY can sort by any output column, including one that DISTINCT has just deduplicated. It also runs before LIMIT, so LIMIT counts deduplicated rows, not the rows before deduplication. A query that selects distinct values and limits to three returns three distinct values, not three rows that collapse to fewer.

Step 7: ORDER BY

ORDER BY sorts the output rows. It runs after SELECT, so it can sort by a column alias, by a computed expression, or by the position of a column in the SELECT list. ORDER BY total_spent DESC and ORDER BY 2 DESC both work in the executed examples in section 6, and both are sorting by the second output column.

Two facts about ORDER BY catch people out. First, without ORDER BY, the database returns rows in whatever order it finds fastest. PostgreSQL documents this plainly: if ORDER BY is not given, the rows are returned in whatever order the system finds fastest to produce. Rows that came back in a convenient order yesterday may come back in a different order tomorrow, after an index is added or the data grows. Any query whose result order matters needs an explicit ORDER BY. Second, ORDER BY is the only clause that sorts. GROUP BY does not promise sorted output, even though grouped results often look sorted, because grouping and sorting are different operations and the database may group without sorting.

Step 8: LIMIT and OFFSET

LIMIT runs last. It takes the sorted result and keeps only the first rows requested, discarding the rest. OFFSET skips a number of rows before LIMIT starts keeping them, which is how paging is done. Because LIMIT runs after ORDER BY, the rows it keeps are the top rows of the sorted result, not the top rows of an unsorted table. ORDER BY amount DESC LIMIT 3 returns the three largest amounts, Ben 310.00, Ava 240.00 and Dee 190.00, as a run against the orders table confirms. Without the ORDER BY, LIMIT 3 would return three rows chosen by whatever order the engine found fastest, which is rarely what the reader wants.

Not every system spells this step the same way. SQL Server uses TOP instead of LIMIT, and Microsoft Learn places TOP as the final step of its logical order, after ORDER BY. PostgreSQL accepts LIMIT or FETCH FIRST. The position is the same in all of them: last, after sorting.

WITH, Window Functions, and UNION

Three more pieces sit around the core sequence. A WITH clause, also called a common table expression, is computed before the main query, and PostgreSQL lists it as the first step of its general processing: all queries in the WITH list are computed, and they serve as temporary tables that can be referenced in the FROM list. Inside a WITH query, the same logical order applies again, because it is a query in its own right.

Window functions, such as SUM(amount) OVER (PARTITION BY region), are computed after WHERE, GROUP BY, and HAVING, and before ORDER BY. They see the grouped result but they do not collapse rows the way GROUP BY does: each input row survives and gains a new column holding the window result. Section 9 shows this running, with each order row carrying its region total beside it.

UNION, INTERSECT, and EXCEPT combine the outputs of two or more SELECT statements into one result set. PostgreSQL places that combination step after DISTINCT and before ORDER BY, which means ORDER BY applies to the combined result, not to each part separately. A query with UNION and ORDER BY sorts the union, and LIMIT after that keeps the top rows of the combined, sorted set.

One Query Traced Through Every Step

The order is easier to trust once it is watched working on real data. The examples in this article all use one small table, orders, with eight rows, and every result shown is the real output of running the query in SQLite through Python sqlite3 on 12 November 2026. The table is created and filled like this:

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer TEXT,
  region TEXT,
  amount REAL,
  order_date TEXT
);

INSERT INTO orders VALUES
 (1, 'Ava', 'North', 120.00, '2026-01-05'),
 (2, 'Ben', 'North',  85.50, '2026-01-06'),
 (3, 'Ava', 'North', 240.00, '2026-01-07'),
 (4, 'Cid', 'South',  60.00, '2026-01-08'),
 (5, 'Ben', 'South', 310.00, '2026-01-09'),
 (6, 'Ava', 'South',  45.00, '2026-01-10'),
 (7, 'Dee', 'North', 190.00, '2026-01-11'),
 (8, 'Cid', 'North',  75.25, '2026-01-12');

The query to trace uses every clause in the core order:

SELECT region, COUNT(*) AS n, SUM(amount) AS total
FROM orders
WHERE amount >= 60
GROUP BY region
HAVING COUNT(*) >= 2
ORDER BY total DESC
LIMIT 1;

Real output:

region | n | total
North  | 5 | 710.75
(1 row)

Now follow the data through the steps, with the row count at each one:

StepWhat happensRows after the step
FROMAll 8 orders are gathered8 rows
WHERE amount >= 60Order 6, Ava in the South for 45.00, is removed7 rows
GROUP BY regionThe 7 rows collapse into 2 groups, North and South2 groups
HAVING COUNT(*) >= 2Both groups have at least 2 rows, so both survive2 groups
SELECTEach group becomes one output row with its count and total2 rows
ORDER BY total DESCNorth at 710.75 sorts above South at 370.002 rows
LIMIT 1Only the first row of the sorted result is kept1 row

Notice what WHERE did to the answer. Order 6 was a 45.00 order from Ava in the South. Removing it before grouping changed the South total from 415.00 to 370.00 and the South count from 3 to 2. If the same threshold had been applied in HAVING instead, it could not have removed a single order at all, because HAVING only sees groups. The totals in the result are the totals of the filtered rows, not of the whole table, and that is a direct consequence of WHERE running before GROUP BY.

The LIMIT step deserves a second look too. It kept one row, North, because North had the larger total after sorting. If LIMIT had run before ORDER BY, it would have kept whichever group happened to come first, and the answer could have been South. The order of the last two steps is what makes “top 1 by total” mean anything.

Why You Cannot Use a SELECT Alias in WHERE

The single most reported consequence of the order is the alias error. A reader writes a query that computes a total and gives it a name, then tries to filter on that name:

SELECT customer, SUM(amount) AS total_spent
FROM orders
WHERE total_spent > 200
GROUP BY customer;

Real output from SQLite:

ERROR: misuse of aggregate: SUM()

The error message names the aggregate rather than the alias, because SQLite resolves the alias back to the expression it stands for, SUM(amount), and then rejects the aggregate inside WHERE. In MySQL, PostgreSQL, and SQL Server the same query fails with a message saying the column does not exist, because those systems do not resolve the alias at all. The wording differs, but the cause is the same in every system: WHERE runs before SELECT, so the alias does not exist yet when WHERE is evaluated.

The MySQL reference manual explains the rule in exactly these terms. It states that standard SQL disallows references to column aliases in a WHERE clause, and that the restriction is imposed because when the WHERE clause is evaluated, the column value may not yet have been determined. Its own example of an illegal query is the same shape as the one above: SELECT id, COUNT(*) AS cnt FROM tbl_name WHERE cnt > 0 GROUP BY id. The manual adds that the WHERE clause determines which rows should be included in the GROUP BY clause, but it refers to the alias of a column value that is not known until after the rows have been selected and grouped.

There are two correct ways to write the query. The first repeats the expression in WHERE, but that only works for expressions that do not aggregate, so it cannot help with a SUM. The second, and the standard fix for an aggregate alias, is to filter the groups in HAVING instead:

SELECT customer, SUM(amount) AS total_spent
FROM orders
GROUP BY customer
HAVING SUM(amount) > 200
ORDER BY total_spent DESC;

HAVING runs after GROUP BY, so the SUM exists by then, and this query returns Ava and Ben, the two customers whose totals exceed 200. The third fix is a subquery, covered in section 10: compute the totals in an inner query, then filter on the alias in the outer query, where the alias is an ordinary column of the subquery result.

One exception is worth knowing, because it is a common source of confusion when moving between databases. SQLite is lenient about aliases in WHERE when the alias stands for a plain expression rather than an aggregate. This query runs in SQLite and returns four rows:

SELECT customer, amount * 2 AS doubled
FROM orders
WHERE doubled > 200;

Real output from SQLite:

customer | doubled
Ava      | 240.0
Ava      | 480.0
Ben      | 620.0
Dee      | 380.0

The alias doubled stands for amount * 2, and SQLite accepts it in WHERE. Standard SQL does not allow this, and MySQL, PostgreSQL, and SQL Server reject it. So a query that works in SQLite can fail the moment it is moved to another database. The safe habit is to write as standard SQL requires and never to filter on an alias in WHERE, even when the database in front of you lets you get away with it. SQLite also accepts an alias in GROUP BY, as the executed example at the end of this section shows, but that too is an extension, and the same warning applies.

SELECT region AS r, SUM(amount) AS total
FROM orders
GROUP BY r
ORDER BY total DESC;

Real output from SQLite:

r     | total
North | 710.75
South | 415.0
(2 rows)

Why ORDER BY Can Use the Alias

ORDER BY runs after SELECT, so by the time sorting happens, the aliases exist. The query that failed in WHERE works unchanged in ORDER BY:

SELECT customer, SUM(amount) AS total_spent
FROM orders
GROUP BY customer
ORDER BY total_spent DESC;

Real output from SQLite:

customer | total_spent
Ava      | 405.0
Ben      | 395.5
Dee      | 190.0
Cid      | 135.25
(4 rows)

ORDER BY can also sort by the position of a column in the SELECT list. ORDER BY 2 DESC means “sort by the second output column,” and it returns the same four rows in the same order:

SELECT customer, SUM(amount) AS total_spent
FROM orders
GROUP BY customer
ORDER BY 2 DESC;

Real output from SQLite:

customer | total_spent
Ava      | 405.0
Ben      | 395.5
Dee      | 190.0
Cid      | 135.25
(4 rows)

Sorting by position is compact, but sorting by alias is clearer, because the alias says what the column means. A reader who later adds a column to the SELECT list will shift the positions and silently change what ORDER BY 2 sorts by, while ORDER BY total_spent keeps working. Positional sorting is also not supported everywhere, so the alias form is the portable choice.

WHERE or HAVING: Which Filter Goes Where

WHERE and HAVING both filter, but they filter different things at different times, and the choice changes the answer. This query filters individual orders first, then filters the groups that remain:

SELECT region, COUNT(*) AS order_count, SUM(amount) AS region_total
FROM orders
WHERE amount >= 100
GROUP BY region
HAVING SUM(amount) > 300;

Real output from SQLite:

region | order_count | region_total
North  | 3           | 550.0
South  | 1           | 310.0
(2 rows)

Follow the numbers. WHERE keeps only the four orders of 100 or more: Ava 120, Ava 240, Dee 190, and Ben 310. Grouping those four by region gives North with three orders totaling 550 and South with one order totaling 310. HAVING then keeps both groups, because both totals exceed 300.

Now move the first condition and see the answer change. If the amount >= 100 test were moved into HAVING, it would be meaningless, because HAVING sees groups, not individual orders, and amount has no single value within a group. If instead the HAVING test were moved into WHERE, it would fail outright, because WHERE cannot use SUM. The two filters are not interchangeable. A condition on a single row belongs in WHERE. A condition on a group, which means any condition using an aggregate, belongs in HAVING.

The practical rule is to filter as early as possible. A condition in WHERE removes rows before grouping, so the database groups fewer rows and does less work. A condition that can only be tested after grouping goes in HAVING, because there is nowhere else for it. Our guide to HAVING in SQL works through more cases, and our guide to count of count in SQL covers the nested counting that often sits beside a HAVING filter.

HAVING also works without GROUP BY. When there is no GROUP BY clause, the whole table is treated as one group, and HAVING tests that single group:

SELECT COUNT(*) AS order_count, SUM(amount) AS grand_total
FROM orders
HAVING SUM(amount) > 1000;

Real output from SQLite:

order_count | grand_total
8           | 1125.75
(1 row)

The eight orders total 1125.75, which exceeds 1000, so the single group survives and one row is returned. If the total had been below 1000, the query would have returned no rows at all. This form is unusual, and a WHERE clause would normally be the wrong tool for it, because the test needs the aggregate that only exists after grouping.

Aggregates Do Not Belong in WHERE

An aggregate function such as SUM, COUNT, or AVG needs a group to work on, and groups do not exist until GROUP BY runs. Putting an aggregate in WHERE asks the database to total rows that have not been grouped yet, and every system rejects it:

SELECT customer, SUM(amount) AS total_spent
FROM orders
WHERE SUM(amount) > 200
GROUP BY customer;

Real output from SQLite:

ERROR: misuse of aggregate: SUM()

The fix is the same as for the alias error: move the aggregate test to HAVING, where the groups exist, or compute the aggregate in a subquery and filter on the result in the outer query. Both patterns are shown in sections 5 and 10. The mistake is worth naming plainly, because it is the error message beginners search for most often after the alias error, and the two errors have the same cause: the clause runs before the thing it refers to exists.

DISTINCT, UNION, and Window Functions

DISTINCT removes duplicate output rows after SELECT computes them. The orders table has two regions, North and South, so selecting the distinct regions returns two rows:

SELECT DISTINCT region FROM orders ORDER BY region;

Real output from SQLite:

region
North
South
(2 rows)

DISTINCT is often used to answer “how many different values are there,” and it is worth remembering that it counts deduplicated output rows, which is not the same as counting input rows. SELECT DISTINCT region returns two rows. SELECT COUNT(DISTINCT region) would return the number 2. And SELECT COUNT(*) returns 8, the number of input rows. Confusing those three is a common source of wrong totals.

Window functions sit in a different place from aggregates, and the order explains why. A window function is computed after WHERE, GROUP BY, and HAVING, but before ORDER BY, and it does not collapse rows. This query adds each region total beside every qualifying order, without removing any row:

SELECT customer, region, amount,
       SUM(amount) OVER (PARTITION BY region) AS region_total
FROM orders
WHERE amount >= 100
ORDER BY region, amount DESC;

Real output from SQLite:

customer | region | amount | region_total
Ava      | North  | 240.0  | 550.0
Dee      | North  | 190.0  | 550.0
Ava      | North  | 120.0  | 550.0
Ben      | South  | 310.0  | 310.0
(4 rows)

WHERE removed the four orders below 100 first, so the window totals are computed over the surviving rows only: 550 for the North and 310 for the South. If WHERE had run after the window function, the totals would have covered all eight orders. The order decides which rows a window function sees, and filtering first is usually what the reader wants. Note also that the window function cannot be used in WHERE, for the same timing reason as an alias: WHERE runs before the window is computed.

UNION combines whole result sets, and it runs after DISTINCT and before ORDER BY in the PostgreSQL sequence. That placement means ORDER BY sorts the combined result. A UNION of two queries followed by ORDER BY sorts everything together, and a LIMIT after that keeps the top rows of the combined set. Sorting each part separately before the UNION does not carry over, because the combination step sits between the parts and the sort.

Subqueries and CTEs Run Their Own Order

Each query level has its own logical order. A subquery in the FROM clause is a complete query, and it runs through FROM, WHERE, GROUP BY, HAVING, and SELECT on its own, before the outer query starts. The outer query then treats the subquery result as a table, and its own WHERE can filter on the subquery columns, including columns that were aliases inside the subquery. That is the clean fix for the alias problem from section 5:

SELECT customer, total_spent
FROM (
  SELECT customer, SUM(amount) AS total_spent
  FROM orders
  GROUP BY customer
) AS totals
WHERE total_spent > 200
ORDER BY total_spent DESC;

Real output from SQLite:

customer | total_spent
Ava      | 405.0
Ben      | 395.5
(2 rows)

Inside the subquery, the totals are computed by GROUP BY and named by SELECT. Outside, total_spent is an ordinary column of the totals result, so the outer WHERE can use it freely. The same pattern works with a WITH clause, which PostgreSQL lists as the first step of its processing: the WITH query is computed first and serves as a temporary table for the main query.

The same reasoning applies to nesting in the other direction. A WHERE condition inside a subquery runs before the outer query sees any rows, so it changes the rows the outer query can work with. A WHERE condition in the outer query runs after the subquery has finished, so it can only remove rows from the subquery result. When a query returns the wrong rows, checking which level each filter sits at is usually the fastest way to find the fault.

The Seven Most Common Mistakes

Each mistake below is a direct consequence of the order, and each has a fix that follows from it.

1. Using a SELECT alias in WHERE. WHERE runs before SELECT, so the alias does not exist yet. Move the test to HAVING if it uses an aggregate, repeat the underlying expression in WHERE if it does not, or compute it in a subquery and filter outside.

2. Putting an aggregate in WHERE. Aggregates need groups, and groups do not exist until GROUP BY runs. Move the test to HAVING.

3. Filtering groups in WHERE instead of HAVING. A test on a group total cannot run before grouping. Use HAVING for any condition that uses an aggregate or a group property.

4. Filtering rows in HAVING instead of WHERE. A test on a single row value can run in HAVING only when the row value is also a grouping column, and even then it runs later than it needs to. Put row tests in WHERE so the database filters before grouping, which is both clearer and cheaper.

5. Expecting sorted output without ORDER BY. No clause except ORDER BY sorts. GROUP BY results often look sorted, and small tables often come back in insertion order, but neither is promised. PostgreSQL states that without ORDER BY the rows come back in whatever order the system finds fastest to produce. Add ORDER BY whenever the order matters.

6. Putting LIMIT before the sort, or forgetting the sort before LIMIT. LIMIT runs last and keeps the first rows of the sorted result. Without ORDER BY, LIMIT keeps whichever rows the engine found fastest, which is not the same as the top rows by any measure the reader cares about. Always pair LIMIT with ORDER BY.

7. Writing a WHERE condition on the wrong side of an outer join. In a LEFT JOIN, a WHERE condition on a right-table column removes the NULL rows that the join deliberately kept, and the outer join silently becomes an inner join. Put the condition in the ON clause when it should decide matching, and keep it in WHERE only when it should remove rows from the joined result.

How the Order Differs Between Database Systems

The core order is the same everywhere, but the edges differ, and those edges are where portable queries break.

FeatureSQL ServerPostgreSQLMySQLSQLite
Core orderFROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BYFROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BYFROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BYFROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY
Row limit stepTOP, after ORDER BYLIMIT or FETCH FIRST, after ORDER BYLIMIT, after ORDER BYLIMIT, after ORDER BY
Alias in GROUP BYNot allowed in standard useAllowed by name or ordinalAllowedAllowed
Alias in HAVINGNot allowed in standard useNot allowedAllowedAllowed
Alias in ORDER BYAllowedAllowedAllowedAllowed
Alias in WHERENot allowedNot allowedNot allowedAllowed for plain expressions, as an extension
Window functionsAfter GROUP BY and HAVING, before ORDER BYAfter GROUP BY and HAVING, before ORDER BYAfter GROUP BY and HAVING, before ORDER BYAfter GROUP BY and HAVING, before ORDER BY

The table entries for SQL Server come from Microsoft Learn, which lists the full logical processing order and states that aliases defined in SELECT cannot be referenced by preceding clauses. The PostgreSQL entries come from the PostgreSQL documentation, which lists its general processing order and notes that output columns can be referenced by name or ordinal in GROUP BY. The MySQL entries come from the MySQL reference manual, which expressly permits aliases in GROUP BY, ORDER BY, and HAVING, and expressly forbids them in WHERE. The SQLite entries come from the SQLite documentation and from the executed examples in this article: the GROUP BY alias query and the WHERE alias query both ran successfully here, while the aggregate alias in WHERE failed.

The practical rule follows from the table. Write queries that work in the strictest system you may ever run them on: never filter on an alias in WHERE, never group by an alias, and never rely on an alias in HAVING. A query written to the standard will run everywhere, while a query written to one system’s extensions will fail when it moves.

A Debugging Checklist for Order Errors

When a query fails or returns the wrong rows, work down this list before rewriting anything.

  1. Read the error message and find which clause it names. “Misuse of aggregate” points at an aggregate in WHERE. “No such column” for a name you defined in SELECT points at an alias used too early.
  2. Check the clause order in the statement. WHERE must come before GROUP BY, HAVING after GROUP BY, ORDER BY near the end, LIMIT last. A clause in the wrong written position is a syntax error, and the fix is to move it.
  3. For each name the query uses, ask which clause created it and whether that clause has run yet at the point of use. A SELECT alias used in WHERE or GROUP BY is the most common failure, and the fix is HAVING, a repeated expression, or a subquery.
  4. For each filter, ask whether it tests a row or a group. Row tests belong in WHERE. Group tests, including anything with an aggregate, belong in HAVING.
  5. Check whether the query returns rows in the expected order. If there is no ORDER BY, the order is not promised, and the fix is to add one rather than to assume the current order will hold.
  6. Check whether LIMIT is paired with ORDER BY. If it is not, the rows kept are the first rows of an unspecified order, and the fix is to add the sort.
  7. Run the query one clause at a time. Remove ORDER BY and LIMIT, then remove HAVING, then remove GROUP BY, and watch the row count at each step. The step where the count changes unexpectedly is the step with the fault, and the stage table in section 4 shows what that trace looks like.

FAQ

What is the SQL order of operations?

It is the logical order in which a database evaluates the clauses of a query: FROM, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, then LIMIT. It differs from the written order, in which SELECT comes first. The logical order decides which names each clause can use and which rows each filter can see.

Why is SELECT written first but evaluated near last?

SELECT is written first because it declares what the query returns, which is what the reader wants to know first. It is evaluated near last because the output columns can only be computed after the source rows have been gathered, filtered, and grouped. The written order serves the reader. The logical order serves the data.

Can I use a SELECT alias in WHERE?

Not in standard SQL, and not in MySQL, PostgreSQL, or SQL Server. WHERE runs before SELECT, so the alias does not exist yet when WHERE is evaluated. SQLite accepts an alias in WHERE for plain expressions, as an extension, but that query will fail in other databases. Use HAVING for aggregate aliases, repeat the expression in WHERE for plain ones, or compute the value in a subquery and filter outside.

Can I use a SELECT alias in ORDER BY?

Yes. ORDER BY runs after SELECT, so aliases are available there in every major system. Sorting by alias is clearer than sorting by column position, because adding a column to the SELECT list does not change what the alias refers to.

What is the difference between WHERE and HAVING?

WHERE filters individual rows before grouping. HAVING filters whole groups after grouping. A condition on a single row value belongs in WHERE. A condition that uses an aggregate, such as a group total or a group count, belongs in HAVING, because aggregates do not exist until the groups are formed.

Does GROUP BY sort the results?

No. GROUP BY groups rows; it does not promise them in any order. Grouped results often look sorted, especially on small tables, but that is a side effect of how the database happened to do the work, not a guarantee. If the order matters, add ORDER BY.

Where does LIMIT fit in the order?

LIMIT runs last, after ORDER BY. It keeps the first rows of the sorted result. Without ORDER BY, LIMIT keeps whichever rows the engine found fastest, which is not the same as the top rows by any measure. Always pair LIMIT with ORDER BY when the choice of rows matters.

Is the logical order the same as the physical execution order?

No. The logical order is a model that defines what the result must be. The physical execution order is chosen by the query optimizer and may read indexes, filter, and sort in a different sequence, as long as the final result matches the logical order. Microsoft Learn and the SQLite documentation both state this. The logical order is what a query writer needs to know; the physical order is the optimizer’s business.

Sources & references

  • Microsoft Learn, "SELECT (Transact-SQL)", learn.microsoft.com/en-us/sql/t-sql/queries/select-transact-sql. Logical processing order of the SELECT statement (FROM, ON, JOIN, WHERE, GROUP BY, WITH CUBE or WITH ROLLUP, HAVING, QUALIFY in Fabric Data Warehouse only, SELECT, DISTINCT, ORDER BY, TOP); aliases defined in SELECT cannot be referenced by preceding clauses but can be referenced by subsequent clauses such as ORDER BY; the query processor determines the actual physical execution and the order might vary from the list. Read 12 November 2026.
  • Microsoft Learn, "Examine the SELECT statement", learn.microsoft.com/en-us/training/modules/introduction-to-transact-sql/4-examine-select-statement. The order in which a SELECT statement is written is not the order in which it is evaluated; FROM first, WHERE next, GROUP BY next, HAVING next, SELECT next, ORDER BY last; aliases created in SELECT cannot be used in GROUP BY or HAVING. Read 12 November 2026.
  • PostgreSQL Documentation 18, "SELECT", postgresql.org/docs/current/sql-select.html. General processing of SELECT: WITH, FROM, WHERE, GROUP BY and HAVING, SELECT output expressions, DISTINCT, UNION, INTERSECT and EXCEPT, ORDER BY, LIMIT or FETCH FIRST, locking clause; output columns can be referenced by name or ordinal number in GROUP BY; without ORDER BY the rows are returned in whatever order the system finds fastest to produce. Read 12 November 2026.
  • SQLite Documentation, "SELECT", sqlite.org/lang_select.html. Simple SELECT presented as FROM, WHERE, GROUP BY and HAVING with result-column expressions, DISTINCT; the step description is purely illustrative and no engine is required to follow it; WHERE excludes rows where the expression is false or NULL; outer join NULL rows are added after ON processing but before WHERE processing. Read 12 November 2026.
  • MySQL 8.4 Reference Manual, "Problems with Column Aliases", dev.mysql.com/doc/refman/8.4/en/problems-with-alias.html. An alias can be used in GROUP BY, ORDER BY, or HAVING; standard SQL disallows references to column aliases in a WHERE clause because when the WHERE clause is evaluated the column value may not yet have been determined. Read 12 November 2026.

Emma Hudson

Editor of Amberaisle — clear, thoughtful explainers for curious readers. More about the editor →