Correlated subquery — extract only each user's "latest order" from the same table
A correlated subquery is a subquery whose inner query references columns of the outer query. Because the inner query runs each time the outer query processes a row, it can dynamically compute a per-row value such as "the maximum value for that row's user."
SELECT * FROM orders o1 WHERE ordered_at = ( SELECT MAX(ordered_at) -- compute the max for o1's user_id FROM orders o2 -- reference the same table under a different alias WHERE o2.user_id = o1.user_id -- reference an outer column (this is the "correlation") );
From the orders table, retrieve only each user's latest order (the row where ordered_at is the maximum). Return order_id, user_id, amount, status, ordered_at ordered by user_id ascending.
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 8,000 | completed | 2024-05-01 |
| 102 | 1 | 12,000 | completed | 2024-05-20 |
| 103 | 2 | 3,500 | completed | 2024-05-10 |
| 104 | 3 | 9,500 | completed | 2024-05-15 |
| 105 | 3 | 11,000 | completed | 2024-05-25 |
| 106 | 4 | 2,000 | completed | 2024-05-08 |
| 107 | 1 | 15,000 | completed | 2024-06-10 |
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 107 | 1 | 15,000 | completed | 2024-06-10 |
| 103 | 2 | 3,500 | completed | 2024-05-10 |
| 105 | 3 | 11,000 | completed | 2024-05-25 |
| 106 | 4 | 2,000 | completed | 2024-05-08 |
SELECT order_id, user_id, amount, status, ordered_at FROM orders o1 -- alias o1 for the outer query WHERE ordered_at = ( -- compare each row's ordered_at with the SQ result SELECT MAX(ordered_at) -- find that user's maximum date FROM orders o2 -- reference the same table under alias o2 (self-reference) WHERE o2.user_id = o1.user_id -- referencing an outer column is the essence of "correlation" ) ORDER BY user_id; /* Execution order (SQL's logical evaluation order): 1. FROM orders o1 → process all 7 rows one at a time 2. Correlated SQ → compare with MAX(ordered_at) per row 3. WHERE filter → narrow to each user's latest order 4. SELECT ... → select the needed columns 5. ORDER BY user_id → user_id ascending */
LEGEND
① FROM orders o1 (outer query)
FROM orders o1Process all 7 rows of the outer query's orders table (o1) one at a time. From here, the correlated subquery is evaluated once per row.| o1.order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 101 | 1 | 8,000 | 2024-05-01 |
| 102 | 1 | 12,000 | 2024-05-20 |
| 103 | 2 | 3,500 | 2024-05-10 |
| 104 | 3 | 9,500 | 2024-05-15 |
| 105 | 3 | 11,000 | 2024-05-25 |
| 106 | 4 | 2,000 | 2024-05-08 |
| 107 | 1 | 15,000 | 2024-06-10 |
o2.user_id = o1.user_id inside makes it compute the MAX for the user_id of the row the outer query is currently processing. Because the inner query re-runs whenever the outer row changes, you get a different aggregate value per row. A non-correlated SQ is fundamentally different in that it is evaluated only once.orders o1 (outer) and orders o2 (inner) are the same table, but they are distinguished by giving them different aliases. Without aliases, "which column is which" becomes ambiguous and raises an error.orders.user_id has no index, a full scan happens as many times as there are outer rows. For large data, a window function such as ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) combined with a CTE is recommended.ORDER BY ordered_at DESC, order_id DESC LIMIT 1, or handle it with a window function plus CTE.FROM orders o WHERE ... = (SELECT MAX(...) FROM orders o WHERE ...) makes the inner o shadow the outer one. Always give the inner and outer queries distinct aliases (o1/o2, etc.).ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) is the first choice in practice. It uses indexes efficiently and lets you control same-date tie-breaks with order_id. It is worth understanding the correlated SQ as a "portable form that works on any RDBMS," so you can handle older environments and exam questions too.HAVING × scalar subquery — filter groups by comparing group aggregates against the overall average
The HAVING clause filters the result of a GROUP BY aggregation. WHERE filters before aggregation (per row); HAVING filters after aggregation (per group). By using a scalar SQ as the comparison value in the HAVING clause, you can dynamically compare an overall aggregate against a group aggregate.
SELECT col, AVG(amount) FROM orders GROUP BY col HAVING AVG(amount) > ( -- post-aggregation group condition SELECT AVG(amount) FROM orders -- scalar SQ: returns the overall average );
WHERE AVG(amount) > ... is a syntax error. Because WHERE is evaluated before GROUP BY, you cannot use aggregate functions in its condition. To filter after aggregation, you must use HAVING.From the orders table, limited to orders whose status is 'completed', retrieve the users whose per-user average order amount is higher than the overall average. Return user_id, avg_amount (the ROUNDed integer) ordered by avg_amount descending.
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8,000 | completed |
| 102 | 1 | 12,000 | completed |
| 103 | 2 | 3,500 | completed |
| 104 | 3 | 9,500 | completed |
| 105 | 3 | 11,000 | completed |
| 106 | 4 | 2,000 | completed |
| 107 | 1 | 15,000 | completed |
| user_id | avg_amount |
|---|---|
| 1 | 11,667 |
| 3 | 10,250 |
SELECT user_id, ROUND(AVG(amount)) AS avg_amount -- ROUND to an integer for display FROM orders WHERE status = 'completed' -- pre-aggregation filter (per row) GROUP BY user_id -- group by user HAVING AVG(amount) > ( -- post-aggregation filter (per group) SELECT AVG(amount) -- scalar SQ: pre-compute the completed overall average FROM orders WHERE status = 'completed' -- match the same condition as the outer query ) ORDER BY avg_amount DESC; /* Execution order (SQL's logical evaluation order): 1. Scalar SQ pre-evaluation → fix the completed average as the threshold 2. FROM orders → read all rows 3. WHERE status='completed' → narrow to completed 4. GROUP BY user_id → aggregate by user 5. HAVING AVG(amount) > thresh → keep groups above the average 6. SELECT ROUND(AVG(amount)) → output the average 7. ORDER BY avg_amount DESC → descending */
LEGEND
① Scalar SQ (fix the overall average)
SELECT AVG(amount) FROM orders WHERE status='completed'The subquery pre-computes, only once, the overall average that becomes the HAVING comparison threshold. The average over all 7 rows (about 8,714) is fixed as a constant.| order_id | user_id | amount | AVG target |
|---|---|---|---|
| 101 | 1 | 8,000 | ✓ included |
| 102 | 1 | 12,000 | ✓ included |
| 103 | 2 | 3,500 | ✓ included |
| 104 | 3 | 9,500 | ✓ included |
| 105 | 3 | 11,000 | ✓ included |
| 106 | 4 | 2,000 | ✓ included |
| 107 | 1 | 15,000 | ✓ included |
WHERE AVG(amount) > ... is evaluated before GROUP BY, so aggregate functions cannot be used there and it is a syntax error. To condition on an aggregate value, you must use HAVING.WHERE status='completed'. Dropping the inner WHERE would make it "the overall average across all statuses," which changes the meaning. It is important to design clearly what you are comparing the average against.WHERE AVG(amount) > 8714 is a syntax error. Aggregate functions cannot be used in the WHERE clause. Always use HAVING for post-aggregation conditions. This is one of the most common pitfalls for beginners.WHERE (average across all statuses), the meaning of "overall average" changes. Deliberately design whether to align or differ the inner and outer filter conditions.EXISTS + NOT EXISTS — extract "ordered but not yet reviewed" users with a compound condition
By combining multiple EXISTS / NOT EXISTS with AND, as in WHERE EXISTS (...) AND NOT EXISTS (...), you can extract in a single query the "rows that satisfy condition A and do not satisfy condition B." This is a field pattern that makes the EXISTS from the basic edition compound.
SELECT * FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- an order exists ) AND NOT EXISTS ( SELECT 1 FROM reviews r WHERE r.user_id = u.user_id -- and no review exists );
From the users table, retrieve users who have at least one completed order yet have never posted a review. Return user_id, name, plan ordered by user_id ascending.
| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
| order_id | user_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 1 | completed |
| 103 | 2 | completed |
| 104 | 3 | completed |
| 105 | 3 | completed |
| 106 | 4 | completed |
| 107 | 1 | completed |
| review_id | user_id | order_id | rating |
|---|---|---|---|
| 1 | 1 | 101 | 5 |
| 2 | 3 | 104 | 4 |
| user_id | name | plan |
|---|---|---|
| 2 | Sato Hanako | free |
| 4 | Yamada Jiro | standard |
SELECT u.user_id, u.name, u.plan FROM users u WHERE EXISTS ( -- condition ①: has at least one completed order SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'completed' -- completed only ) AND NOT EXISTS ( -- condition ②: has no review at all SELECT 1 FROM reviews r WHERE r.user_id = u.user_id -- confirm no row exists in reviews ) ORDER BY u.user_id; /* Execution order (SQL's logical evaluation order): 1. FROM users u → process one row at a time 2. EXISTS (completed order) → decide "has a completed one" 3. AND NOT EXISTS (review absent) → decide "has no review" 4. SELECT u.user_id, u.name, u.plan → select passing rows 5. ORDER BY u.user_id → user_id ascending */
LEGEND
① FROM users u (read all rows)
FROM users uProcess all 5 rows of the users table one at a time. For each row, the two subqueries EXISTS and NOT EXISTS are evaluated in turn.| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
user_id of the reviews table, NOT EXISTS works as expected. In contrast, NOT IN (SELECT user_id FROM reviews) risks returning 0 rows because everything becomes UNKNOWN if even one NULL exists (see basic edition Q3). In practice, make NOT EXISTS your first choice.WHERE user_id NOT IN (SELECT user_id FROM reviews) excludes all rows the moment a NULL enters reviews.user_id. Because reviews can acquire NULLs during operation, always use NOT EXISTS, or add WHERE user_id IS NOT NULL to the inner query.LEFT JOIN reviews ON ... WHERE reviews.user_id IS NULL, but EXISTS/NOT EXISTS is clearer in that it only "checks existence" and does not add selected columns. If you also want the joined table's columns in SELECT, use JOIN; if you only need an existence check, EXISTS expresses the intent more clearly.Derived table × INNER JOIN — join user info onto an aggregate summary to return a list
In the basic edition, a FROM-clause subquery (derived table) grouped and aggregated, and the outer WHERE filtered it. Extending this pattern further, INNER JOINing the aggregate result (derived table) with another master table to obtain aggregate values and related information in a single query is the most frequent pattern in practice.
SELECT m.name, s.total FROM master m INNER JOIN ( SELECT id, SUM(amount) AS total FROM transactions GROUP BY id ) AS s ON m.id = s.id; -- join the aggregate result with the master
Aggregate the orders table to obtain each user's order count, total amount, and average amount, and combine them with the name / plan of the users table to return the result. Consider completed orders only, and return user_id, name, plan, order_count, total_amount, avg_amount ordered by total_amount descending.
| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8,000 | completed |
| 102 | 1 | 12,000 | completed |
| 103 | 2 | 3,500 | completed |
| 104 | 3 | 9,500 | completed |
| 105 | 3 | 11,000 | completed |
| 106 | 4 | 2,000 | completed |
| 107 | 1 | 15,000 | completed |
| user_id | name | plan | order_count | total_amount | avg_amount |
|---|---|---|---|---|---|
| 1 | Tanaka Taro | premium | 3 | 35,000 | 11,667 |
| 3 | Suzuki Ichiro | premium | 2 | 20,500 | 10,250 |
| 2 | Sato Hanako | free | 1 | 3,500 | 3,500 |
| 4 | Yamada Jiro | standard | 1 | 2,000 | 2,000 |
SELECT u.user_id, u.name, u.plan, s.order_count, s.total_amount, s.avg_amount FROM users u INNER JOIN ( -- INNER JOIN the aggregate result as a derived table SELECT user_id, COUNT(*) AS order_count, -- order count SUM(amount) AS total_amount,-- total amount ROUND(AVG(amount)) AS avg_amount -- average amount (rounded to integer) FROM orders WHERE status = 'completed' -- narrow to completed before aggregating GROUP BY user_id ) AS s ON u.user_id = s.user_id -- the AS alias is required; specify the join key with ON ORDER BY s.total_amount DESC; /* Execution order (SQL's logical evaluation order): 1. Evaluate the derived table (s) 2. FROM users u → read all 5 rows of users 3. INNER JOIN s ON user_id → user5 has no row in s, so it is auto-excluded from the join 4. SELECT u.*, s.* → select the needed columns 5. ORDER BY s.total_amount DESC → total amount descending */
LEGEND
① Generate the derived table (s)
SELECT user_id, COUNT(*), SUM(amount), ROUND(AVG(amount)) FROM orders WHERE status='completed' GROUP BY user_idThe inner subquery is evaluated first, generating in memory a "virtual table (s)" holding the per-user aggregate result. At this point, user5 with no orders is not included.| user_id | order_count | total_amount | avg_amount |
|---|---|---|---|
| 1 | 3 | 35,000 | 11,667 |
| 2 | 1 | 3,500 | 3,500 |
| 3 | 2 | 20,500 | 10,250 |
| 4 | 1 | 2,000 | 2,000 |
AS s. Omitting the alias is an error in both PostgreSQL and MySQL. In the outer query, reference columns via the alias, as in s.order_count.AS s to a FROM-clause subquery is a syntax error (required in both PostgreSQL and MySQL). Column names referenced from the outside may also raise an ambiguity error unless referenced via the alias.WITH order_summary AS (SELECT user_id, COUNT(*) AS order_count, ... FROM orders WHERE status='completed' GROUP BY user_id) SELECT u.*, s.* FROM users u INNER JOIN order_summary s ON u.user_id = s.user_id ORDER BY s.total_amount DESC; lets you split the aggregation logic out of the main body. CTEs are especially effective for complex queries that need multiple derived tables, making debugging and review far easier.Multi-level nested SQ — get the product list of the "best-selling category" via nested subqueries
A subquery can contain yet another subquery (multi-level nesting). By narrowing step by step from the outside inward — "extract the category's maximum sales → identify that category name → extract that category's products" — you can express complex conditions.
SELECT * FROM products WHERE category = ( SELECT category -- ② find the category name FROM sales_summary WHERE total = ( SELECT MAX(total) -- ① fix the maximum value first FROM sales_summary ) );
Using the following 3 tables, retrieve the product list of the category with the largest total sold quantity (qty) among completed orders (completed). Return product_id, name, category, price ordered by price descending.
| product_id | name | category | price |
|---|---|---|---|
| 1 | Wireless Earbuds | electronics | 8,000 |
| 2 | Smartwatch | electronics | 25,000 |
| 3 | Cotton T-shirt | apparel | 3,500 |
| 4 | Denim Jacket | apparel | 12,000 |
| 5 | Protein Powder | health | 5,000 |
| order_id | status |
|---|---|
| 101 | completed |
| 102 | completed |
| 103 | completed |
| 104 | pending |
| item_id | order_id | product_id | qty |
|---|---|---|---|
| 1 | 101 | 1 | 2 |
| 2 | 101 | 3 | 1 |
| 3 | 102 | 2 | 1 |
| 4 | 102 | 1 | 3 |
| 5 | 103 | 4 | 2 |
| 6 | 103 | 5 | 1 |
| 7 | 104 | 2 | 2 |
| product_id | name | category | price |
|---|---|---|---|
| 2 | Smartwatch | electronics | 25,000 |
| 1 | Wireless Earbuds | electronics | 8,000 |
SELECT product_id, name, category, price FROM products WHERE category = ( -- extract products matching the best-selling category name SELECT p2.category -- ② get the category name (scalar SQ) FROM products p2 INNER JOIN order_items oi ON p2.product_id = oi.product_id INNER JOIN orders o ON oi.order_id = o.order_id WHERE o.status = 'completed' -- completed orders only GROUP BY p2.category HAVING SUM(oi.qty) = ( -- per-category total qty equals the maximum value SELECT MAX(cat_qty) -- ① fix the maximum total qty first (innermost SQ) FROM ( SELECT SUM(oi2.qty) AS cat_qty -- per-category total qty FROM products p3 INNER JOIN order_items oi2 ON p3.product_id = oi2.product_id INNER JOIN orders o2 ON oi2.order_id = o2.order_id WHERE o2.status = 'completed' GROUP BY p3.category ) AS cat_totals -- always give the derived table an alias ) ) ORDER BY price DESC; /* Execution order (SQL's logical evaluation order): 1. Evaluate the innermost SQ (derived table cat_totals) 2. 2nd-level SQ: MAX(cat_qty) = 6 (fix the maximum value) 3. Middle SQ: get the category where HAVING SUM(oi.qty) = 6 4. Outer WHERE: narrow to products.category = 'electronics' 5. SELECT ... → select the needed columns 6. ORDER BY price DESC → price descending */
LEGEND
① Innermost SQ (fetch and join data)
FROM products p3 INNER JOIN order_items oi2 ... WHERE o2.status='completed'Evaluation begins from the deepest subquery. First, join the completed order line items (order_items) with product info (products) to prepare the base data for aggregation.| o2.order_id | category | qty | status |
|---|---|---|---|
| 101 | electronics | 2 | completed |
| 101 | apparel | 1 | completed |
| 102 | electronics | 1 | completed |
| 102 | electronics | 3 | completed |
| 103 | apparel | 2 | completed |
| 103 | health | 1 | completed |
| 104 | electronics | 2 | pending (excluded) |
SELECT MAX(cat_qty) FROM (...) is evaluated first and returns a constant value (6). Next, the middle SQ gets the category name with HAVING SUM(oi.qty) = 6, and finally the outer query extracts that category's products. When designing, the trick is to work backward from the inside, asking "what should I fix in the inner query so the outer becomes simple."WITH cat_totals AS (...) SELECT MAX(cat_qty) FROM cat_totals.LIMIT 1 or ORDER BY ... LIMIT 1, or switches to IN to handle multiple matches.WITH cat_totals AS (...per-category qty aggregation...) ② top_category AS (SELECT category FROM cat_totals WHERE cat_qty = (SELECT MAX(cat_qty) FROM cat_totals)) ③ SELECT * FROM products WHERE category IN (SELECT category FROM top_category) — this also makes debugging each step easier.