SQL Order of Operations: The Logical Execution Order Behind Every Query Trap

Trace SQL logical processing from joins and filters through grouping, windows, DISTINCT, ordering, and LIMIT with verified PostgreSQL examples.

Author: PracHub

Published: 8/14/2026

SQL Order of Operations: The Logical Execution Order Behind Every Query Trap

By PracHub
August 14, 2026
24 min read
0
SQL Order of Operations: The Logical Execution Order Behind Every Query Trap

Quick Overview

A coherent SQL order-of-operations guide for Data Analysts. Five PostgreSQL walkthroughs explain outer-join predicates, alias scope, WHERE versus HAVING, post-HAVING windows, and deterministic DISTINCT, ORDER BY, and LIMIT behavior.

Data AnalystFree

SQL is written from SELECT down, but one query block is reasoned about in a different order:

FROM and JOINWHEREGROUP BYHAVINGSELECT and window functions → DISTINCTORDER BYLIMIT

This is a logical model for understanding the result, not a promise about the physical plan PostgreSQL will execute. Its value is practical: it tells you which rows and names exist at each stage.

The logical processing map

StageWhat it doesWhat exists afterward
FROM / JOINbuilds the working row setjoined columns and any null-padded outer-join rows
WHEREremoves individual rowsrows allowed to enter grouping
GROUP BYcollapses rows into groupsone logical group per key combination
HAVINGremoves groupsgroups allowed to reach the output calculation
SELECT / windowscomputes output expressions and window valuesprojected rows with aliases
DISTINCTremoves duplicate projected rowsunique output rows
ORDER BYsorts the remaining rowsan ordered result
LIMITtruncates that orderthe requested prefix of the result

Each CTE or subquery is a new query block with its own sequence. That reset is why an outer WHERE can filter an alias or window value created by an inner block.

FROM and WHERE: population and alias visibility

ON decides which rows match while the join is being built. A right-table condition in WHERE runs later and rejects a null-padded row because the condition is not true for NULL. Put the status condition in ON when every customer must remain in the output.

Input: customers

customer_idcustomer_name
1Ana
2Ben
3Cy

Input: orders

order_idcustomer_idstatusamount
1011paid50.00
1021cancelled20.00
1032paid40.00
SELECT
  c.customer_id,
  c.customer_name,
  COUNT(o.order_id) AS paid_orders
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
 AND o.status = 'paid'
GROUP BY c.customer_id, c.customer_name
ORDER BY c.customer_id;
Logical row flow for an outer join predicate Three customers are left joined only to paid orders, preserving the customer with no orders, then grouped into three customer-level counts. 3 customersleft-side populationJOIN paid matcheskeep null-padded Cy3 output rowspaid counts: 1, 1, 0
The match condition is part of the join, so it does not filter the preserved customer population afterward.

Output

customer_idcustomer_namepaid_orders
1Ana1
2Ben1
3Cy0

If the condition were WHERE o.status = 'paid', Cy's null-padded row would be removed. See SQL joins and SQL COUNT for the two separate decisions: preserving the population and counting only matched rows.

Alias visibility follows the same stage order. An output alias is created in SELECT, so a WHERE adjusted_amount >= 110 in that same block cannot refer to it. Give the calculation its own block, then the outer block can filter the ordinary column.

Input: sales

sale_idrepamount
1Ana100.00
2Ana50.00
3Ben120.00
4Cy80.00
WITH priced AS (
  SELECT
    sale_id,
    rep,
    ROUND(amount * 1.10, 2) AS adjusted_amount
  FROM sales
)
SELECT sale_id, rep, adjusted_amount
FROM priced
WHERE adjusted_amount >= 110
ORDER BY sale_id;
Logical row flow for filtering a calculated alias Four sale rows receive adjusted amounts in an inner select, then an outer where clause filters the now-visible alias to two rows. 4 sale rowsbase amount existsInner SELECTcreates adjusted_amountOuter WHEREkeeps 2 rows
The CTE completes one logical query block; the alias is an input column to the next block.

Output

sale_idrepadjusted_amount
1Ana110.00
3Ben132.00

Repeating the calculation in WHERE would also be valid here. A second block is especially useful when the expression is long or when the value comes from a window function.

GROUP BY and HAVING: rows become groups

WHERE filters sale rows before grouping. HAVING filters the grouped totals afterward. This query first excludes sales below 60, then keeps only reps whose remaining total reaches 100.

Input: sales

sale_idrepamount
1Ana100.00
2Ana50.00
3Ben120.00
4Cy80.00
SELECT
  rep,
  SUM(amount) AS qualifying_sales
FROM sales
WHERE amount >= 60
GROUP BY rep
HAVING SUM(amount) >= 100
ORDER BY qualifying_sales DESC, rep;
Logical row flow through WHERE, GROUP BY, and HAVING Four sale rows are filtered to three rows of at least sixty, grouped into three rep totals, and filtered to the two groups totaling at least one hundred. 4 sale rowsWHERE keeps 3GROUP BY rep3 totalsHAVING keeps 2then ORDER BY
Ana's 50.00 sale never enters her group, while Cy's 80.00 group forms and is then removed.

Output

repqualifying_sales
Ben120.00
Ana100.00

Moving the row condition to HAVING would not be an equivalent rewrite. After grouping by rep, there is no single unaggregated amount for the clause to inspect. Likewise, WHERE SUM(amount) >= 100 is invalid because the aggregate does not exist at the WHERE stage.

Window functions see post-HAVING rows

Window functions are evaluated after grouping and group filtering in their query block. Here the inner query creates rep totals and removes totals below 100. The outer query ranks only the surviving groups.

Input: sales

sale_idrepamount
1Ana100.00
2Ana50.00
3Ben120.00
4Cy80.00
WITH rep_totals AS (
  SELECT rep, SUM(amount) AS total_sales
  FROM sales
  GROUP BY rep
  HAVING SUM(amount) >= 100
)
SELECT
  rep,
  total_sales,
  DENSE_RANK() OVER (ORDER BY total_sales DESC) AS sales_rank
FROM rep_totals
ORDER BY sales_rank, rep;
Logical row flow from groups to window ranks Four sales become three rep totals, having removes Cy's group, and dense rank assigns positions to the remaining Ana and Ben totals. 4 sales3 rep groupsHAVING2 totals surviveDENSE_RANKranks 2 rows
Cy is absent before the window begins, so the ranking operates on two rows.

Output

reptotal_salessales_rank
Ana150.001
Ben120.002

A window alias also cannot be filtered by WHERE in the same block. Compute it in a CTE or subquery and filter it from the outer block, just as the alias example did. The window-functions guide covers partitioning, ties, and frames.

DISTINCT, ORDER BY, and LIMIT finish the block

DISTINCT removes duplicate projected rows, ORDER BY sorts those rows, and LIMIT keeps the requested prefix. This query asks for the first two page names alphabetically, not for two arbitrary event rows.

Input: page_views

view_iduser_idpage_nameviewed_at
11pricing2026-08-01 09:00
22home2026-08-01 09:05
31pricing2026-08-01 09:10
43docs2026-08-01 09:15
54home2026-08-01 09:20
SELECT DISTINCT page_name
FROM page_views
ORDER BY page_name
LIMIT 2;
Logical row flow through DISTINCT, ORDER BY, and LIMIT Five page view rows project to page names, collapse to three distinct names, sort alphabetically, and truncate to docs and home. 5 view rows3 page namesDISTINCT then sortdocs, home, pricingLIMIT 2docs, home
Deduplication happens before truncation, so repeated views do not consume the two output slots.

Output

page_name
docs
home

With SELECT DISTINCT, PostgreSQL requires an ORDER BY expression to appear in the select list. After duplicate page names collapse, an omitted detail such as view_id no longer identifies a single value for each output row. Also give LIMIT a deterministic ORDER BY; without it, the database is not being asked for a stable subset. Practice these stage decisions with the SQL practice questions.

FAQ

Is logical order the same as execution order?

No. Logical order explains query semantics. PostgreSQL can push filters, choose different join algorithms, or reorder eligible joins while preserving the same result. Use EXPLAIN to inspect the selected physical plan.

Why can ORDER BY use a SELECT alias while WHERE cannot?

The alias exists after the SELECT stage. ORDER BY comes later in the logical block; WHERE comes earlier. A CTE or subquery turns the alias into an input column for a new block.

Does HAVING always require GROUP BY?

Not in PostgreSQL. Without GROUP BY, the input can be treated as one group, so HAVING can keep or remove that aggregate result. Use it when that one-group meaning matches the question.

Where should I filter a window result?

In an outer query block. Compute the window value in a CTE or subquery, then apply WHERE outside. That is not a workaround for a broken engine; it follows from the value first existing at the window stage.

What order should I memorize?

Memorize enough to trace row sets: join, filter rows, group, filter groups, calculate outputs and windows, deduplicate, sort, truncate. More importantly, practice naming what exists after each step. That reasoning catches more bugs than reciting clause names alone.


Comments (0)