WHERE NOT EXISTS (subquery) keeps a row when the subquery "returns no rows at all." It has none of the NULL pitfalls of NOT IN and is the recommended pattern for taking a set difference in practice.
SELECT * FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- passes if zero matching rows );
From the users table, retrieve users who have no order with status 'completed' (incomplete users = dormant candidates). Return user_id, name, plan in ascending order of user_id.
| 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 | pending |
| 104 | 3 | completed |
| 105 | 3 | completed |
| 106 | 4 | cancelled |
Expected output (no completed, user_id ascending):
| user_id | name | plan |
|---|---|---|
| 2 | Sato Hanako | free |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
※ user_id 1 and 3 have completed orders → excluded. 2 (pending), 4 (cancel), and 5 (no orders) pass.
SELECT user_id, name, plan FROM users u WHERE NOT EXISTS ( -- passes when the SQ returns 0 rows (no row exists) SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- correlated SQ referencing an outer column AND o.status = 'completed' ) ORDER BY user_id; /* Execution order (logical evaluation order in SQL): 1. FROM users u → process one row at a time 2. NOT EXISTS(...) → run the subquery per user 3. SELECT user_id, name, plan → select the passing rows 4. ORDER BY user_id → user_id ascending */
LEGEND
① FROM users
FROM users uProcess all 5 rows of the users table one at a time. For each row, the NOT EXISTS subquery is evaluated.| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
LEFT JOIN orders o ON u.user_id=o.user_id AND o.status='completed' WHERE o.user_id IS NULL produces the same result. This is a pattern you often see in queries generated by an ORM.WHERE user_id NOT IN (SELECT user_id FROM orders) returns 0 rows for everything. Simply switching to NOT EXISTS makes it work correctly.WHERE o.user_id = u.user_id inside EXISTS / NOT EXISTS, then every user passes/is excluded as long as orders has even one row. The correlation condition is mandatory.NOT EXISTS (SELECT 1 FROM orders WHERE user_id = u.user_id AND status='completed' AND ordered_at >= NOW() - INTERVAL '30 days'). NOT EXISTS is highly flexible with conditions and is a staple pattern for real-world batch queries.A correlated subquery is a form in which the inner subquery references the value of the row currently being processed by the outer query. Each time the outer query processes a row, the inner query runs. Use it for patterns that contrast a per-group aggregate with each row, such as "compare each order against that user's average."
SELECT o.order_id, o.amount FROM orders o WHERE o.amount > ( SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id -- references the outer o.user_id (correlation condition) );
From the orders table, retrieve only orders that exceed each user's average order amount. The columns to retrieve are order_id, user_id, amount, user_avg (attaching that user's average), ordered by user_id ascending and, within the same user, by amount descending.
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
Expected output (only orders exceeding each user's average):
| order_id | user_id | amount | user_avg |
|---|---|---|---|
| 102 | 1 | 12000 | 10000 |
| 104 | 3 | 9500 | 7750 |
※ user2 and user4, who have only one order each, have average = their own amount, so the "exceeds" condition is always FALSE. Only 102 (12000>10000) passes for user1, and only 104 (9500>7750) passes for user3.
SELECT o.order_id, o.user_id, o.amount, (SELECT ROUND(AVG(o2.amount)) -- correlated SQ: returns that user's average FROM orders o2 WHERE o2.user_id = o.user_id -- references the outer o.user_id (correlation condition) ) AS user_avg FROM orders o WHERE o.amount > ( -- compare against each user's average to filter SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id -- the same correlated SQ used in WHERE ) ORDER BY o.user_id, o.amount DESC; /* Execution order (logical evaluation order in SQL): 1. FROM orders o → process every row one at a time 2. WHERE o.amount > (correlated SQ) → run the subquery for each row 3. SELECT ... user_avg → attach each user's average via the correlated SQ 4. ORDER BY user_id, amount DESC → sort */
LEGEND
① FROM orders
FROM orders oProcess all 6 rows of the orders table one at a time. For each row, the correlated subquery runs.| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
FROM orders o (outer) and FROM orders o2 (inner), the correlation condition o2.user_id = o.user_id becomes meaningful. Forget the alias and you get an unintended self-reference.AVG(amount) OVER (PARTITION BY user_id), which is also better for performance. A correlated SQ runs the inner query per row, so it can be slow when there is a lot of data.WHERE o2.user_id = o.user_id, the comparison is against the average of all orders (6833), and it behaves like an ordinary scalar SQ rather than a correlated subquery. Always double-check the correlation condition.AVG(amount) OVER (PARTITION BY user_id) needs only a single table scan and improves performance dramatically. However, because you cannot use a window function directly in WHERE, you need to wrap it in a derived table or CTE after SELECT.To "retrieve the highest-priced product in each category," a useful pattern is to first aggregate the MAX price per category and then JOIN that result table with the original table. You can also write it with a correlated subquery, but a derived table + JOIN is more efficient on large data.
SELECT p.* FROM products p INNER JOIN ( SELECT category, MAX(price) AS max_price -- aggregate MAX in a derived table FROM products GROUP BY category ) AS cat_max ON p.category = cat_max.category AND p.price = cat_max.max_price; -- only rows matching the MAX value
From the products table, retrieve the highest-priced product in each category. The columns to retrieve are product_id, name, category, price, ordered by price descending.
| product_id | name | category | price |
|---|---|---|---|
| 1 | Plan A | service | 9800 |
| 2 | Plan B | service | 4900 |
| 3 | Template X | content | 5500 |
| 4 | Template Y | content | 3800 |
| 5 | API Add-on | option | 3500 |
| 6 | Support Extension | option | 2200 |
Expected output (only the highest-priced product per category, price descending):
| product_id | name | category | price |
|---|---|---|---|
| 1 | Plan A | service | 9800 |
| 3 | Template X | content | 5500 |
| 5 | API Add-on | option | 3500 |
※ service max=9800 (Plan A) / content max=5500 (Template X) / option max=3500 (API Add-on)
SELECT p.product_id, p.name, p.category, p.price FROM products p INNER JOIN ( -- place the derived table on the right side of the JOIN SELECT category, MAX(price) AS max_price -- aggregate the highest price per category FROM products GROUP BY category ) AS cat_max -- always give the derived table an alias ON p.category = cat_max.category -- category matches AND p.price = cat_max.max_price -- and price matches the MAX value ORDER BY p.price DESC; /* Execution order (logical evaluation order in SQL): 1. Evaluate the derived table 2. FROM products p → read all of products 3. INNER JOIN ... ON ... → join only rows where category matches and price = MAX value 4. SELECT p.product_id... → select the needed columns 5. ORDER BY p.price DESC → sort by price descending */
LEGEND
① Derived table (GROUP BY)
SELECT category, MAX(price) FROM products GROUP BY categoryFirst the inner query of the derived table runs. It aggregates the highest price for each of the 3 categories.| product_id | name | category | price | group |
|---|---|---|---|---|
| 1 | Plan A | service | 9800 | service group |
| 2 | Plan B | service | 4900 | service group |
| 3 | Template X | content | 5500 | content group |
| 4 | Template Y | content | 3800 | content group |
| 5 | API Add-on | option | 3500 | option group |
| 6 | Support Extension | option | 2200 | option group |
AND in the JOIN's ON clause. Only rows that satisfy both the category match and the price match remain in the join result. Writing it in WHERE gives the same result, but placing it in ON as a join condition makes the intent clearer.WHERE price = MAX(price) produces a syntax error. Aggregate functions cannot be used directly in the WHERE clause. Go through a derived table or use a window function.WHERE price = (SELECT MAX(price) FROM products p2 WHERE p2.category = p.category) gives the same result, but it runs the inner query per row and becomes slow on large data. A derived table + JOIN is more efficient.ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) and filter with WHERE rank <= 3. Keep the subquery + JOIN pattern in mind as a fallback for environments where window functions are unavailable.A CTE (Common Table Expression) is defined with WITH name AS (subquery) and is a "temporary named table" that subsequent queries can reference by name. It is equivalent to a FROM-clause subquery (derived table) in many cases, but is superior in readability and reusability.
-- ■ Subquery version (deeply nested) SELECT * FROM ( SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ) AS sub WHERE cnt >= 2; -- ■ CTE version (flat and easy to read) WITH sub AS ( SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ) SELECT * FROM sub WHERE cnt >= 2;
Using the orders table, aggregate the order count (order_cnt) and total amount (total_amount) per status, and return only the statuses whose order_cnt is 2 or more, by total_amount descending. Answer with both the subquery version and the CTE version.
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
Expected output (order_cnt ≥ 2, total_amount descending):
| status | order_cnt | total_amount |
|---|---|---|
| completed | 4 | 35500 |
※ pending (1) and cancelled (1) are excluded because order_cnt < 2. Only completed (4 orders: 8000+12000+9500+6000=35500) remains.
-- ■ Subquery version (derived table) SELECT status, order_cnt, total_amount FROM ( SELECT status, COUNT(*) AS order_cnt, -- aggregate the count SUM(amount) AS total_amount -- aggregate the total amount FROM orders GROUP BY status ) AS summary -- always give the derived table an alias WHERE order_cnt >= 2 -- filter by the post-aggregation count ORDER BY total_amount DESC; -- ■ CTE version (WITH clause) — returns exactly the same result as above WITH summary AS ( -- CTE definition (name: summary) SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status ) SELECT -- reference by CTE name (just like an ordinary table) status, order_cnt, total_amount FROM summary WHERE order_cnt >= 2 ORDER BY total_amount DESC; /* Execution order (the same logical evaluation order for both): 1. Evaluate the aggregation query (SQ or CTE) → aggregate per status 2. FROM summary → reference the aggregated result 3. WHERE order_cnt threshold → filter by the condition 4. SELECT ... → select the 3 columns 5. ORDER BY total_amount DESC → total descending */
LEGEND
① Inner query (GROUP BY)
SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY statusFirst the inner query of the derived table runs. It groups orders by status and aggregates the count and total.| order_id | status | amount | group |
|---|---|---|---|
| 101 | completed | 8000 | completed group |
| 102 | completed | 12000 | completed group |
| 103 | pending | 3500 | pending group |
| 104 | completed | 9500 | completed group |
| 105 | completed | 6000 | completed group |
| 106 | cancelled | 2000 | cancelled group |
LEGEND
① CTE definition (WITH clause)
WITH summary AS (SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status)Define the query as a CTE named "summary." What it does is exactly the same as the subquery version. Its distinguishing feature is that it can be written flat without nesting.| order_id | status | amount | group |
|---|---|---|---|
| 101 | completed | 8000 | completed group |
| 102 | completed | 12000 | completed group |
| 103 | pending | 3500 | pending group |
| 104 | completed | 9500 | completed group |
| 105 | completed | 6000 | completed group |
| 106 | cancelled | 2000 | cancelled group |
WITH a AS (...), b AS (...) SELECT ...); ② when nesting reaches 3 levels or more; ③ when you want to check intermediate results step by step while debugging.FROM (FROM (FROM ...)) should be decomposed with CTEs—this is the practical standard.WITH active_users AS (...), recent_orders AS (...), summary AS (...) SELECT ... lets the SQL function as documentation as well. Use "if a subquery exceeds 3 levels, move to a CTE" as a rule of thumb.Real-world SQL often combines several kinds of subqueries. In this problem you combine ① WHERE IN + HAVING (identify high-value users), ② a scalar SQ (correlated) (get the latest order date), and ③ a scalar SQ (correlated) (attach the order count) into a single query.
Write a query that satisfies the following conditions:
① From the orders table, retrieve only the single latest order of each user whose total completed amount is 10,000 or more
② The columns to retrieve are name (from users), order_id, amount, ordered_at, total_orders (that user's total number of orders)
③ Sort by total_orders descending, and by amount descending for the same count.
| 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 | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 8000 | completed | 2024-05-01 |
| 102 | 1 | 12000 | completed | 2024-05-20 |
| 103 | 2 | 3500 | pending | 2024-05-10 |
| 104 | 3 | 9500 | completed | 2024-05-15 |
| 105 | 3 | 6000 | completed | 2024-05-25 |
| 106 | 4 | 2000 | cancelled | 2024-05-08 |
Expected output (latest order of users with total ≥ 10000, total_orders descending):
| name | order_id | amount | ordered_at | total_orders |
|---|---|---|---|---|
| Tanaka Taro | 102 | 12000 | 2024-05-20 | 2 |
| Suzuki Ichiro | 105 | 6000 | 2024-05-25 | 2 |
※ user1 (completed total 20000≥10000) / user3 (completed total 15500≥10000) → latest order of each user: user1→102 (2024-05-20) / user3→105 (2024-05-25)
SELECT u.name, o.order_id, o.amount, o.ordered_at, (SELECT COUNT(*) -- correlated scalar SQ: attach the total order count FROM orders o3 WHERE o3.user_id = u.user_id ) AS total_orders FROM users u INNER JOIN orders o ON u.user_id = o.user_id WHERE u.user_id IN ( -- ① WHERE IN + HAVING: identify high-value users SELECT user_id FROM orders WHERE status = 'completed' GROUP BY user_id HAVING SUM(amount) >= 10000 -- list of user_ids whose completed total is 10000 or more ) AND o.ordered_at = ( -- ② correlated scalar SQ: only the row matching the latest order date SELECT MAX(o2.ordered_at) FROM orders o2 WHERE o2.user_id = u.user_id ) ORDER BY total_orders DESC, o.amount DESC; /* Execution order (logical evaluation order in SQL): 1. Evaluate the IN subquery → generate the list via a HAVING aggregation 2. FROM users INNER JOIN orders → join on user_id 3. WHERE user_id IN (...) → narrow to the matching users 4. AND o.ordered_at = (correlated scalar SQ) → narrow to each user's latest order 5. SELECT (correlated scalar SQ) → attach the total order count 6. ORDER BY total_orders DESC, o.amount DESC → count → amount descending */
LEGEND
① IN + HAVING subquery
SELECT user_id FROM orders WHERE status='completed' GROUP BY user_id HAVING SUM(amount)>=10000First group completed orders by user_id and list only the user_ids whose total is 10000 or more. This is used to narrow users in the outer query.| user_id | SUM(completed) | HAVING >= 10000? |
|---|---|---|
| 1 | 8000+12000=20000 | ✓ add to [1,3] |
| 2 | 0 (no completed) | ✗ excluded |
| 3 | 9500+6000=15500 | ✓ add to [1,3] |
| 4 | 0 (no completed) | ✗ excluded |
| 5 | (no orders) | ✗ excluded |
ordered_at = (SELECT MAX(ordered_at) FROM orders WHERE user_id = u.user_id) is the standard pattern for retrieving "each user's latest order." Placing a correlated SQ with MAX in an ON or AND condition lets you keep only the latest row while joining.GROUP BY + HAVING inside the IN subquery, you can dynamically generate a list of "user IDs whose total amount is above a threshold" in a single query. HAVING is a filter condition that can be applied only to aggregate values after GROUP BY.WITH high_value_users AS (...), latest_orders AS (...), summary AS (...) SELECT ....total_orders) and a correlated SQ in the WHERE clause (the latest ordered_at) as well. As the number of rows grows, evaluation equivalent to O(N²) can occur. In production, check the execution plan with EXPLAIN and replace with window functions or CTEs as needed.