When retrieving customers who have placed an order, an INNER JOIN between customers and orders duplicates a customer once for each order because of the 1:N relationship. You then need DISTINCT to remove duplicates, which adds waste and creates opportunities for bugs.
EXISTS (a semi-join) checks only whether at least one matching row exists in the right table. It filters the left table without increasing its rows or bringing in columns from the right. This is the correct existence check when you need to preserve cardinality.
-- INNER JOIN: customer rows are duplicated by order count → DISTINCT is needed FROM customers c JOIN orders o ON c.customer_id = o.customer_id -- Suzuki expands to 3 rows -- EXISTS (semi-join): one matching order is enough, so the row stays single and no columns are added FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)
1 is conventional. Evaluation often stops as soon as the first row is found (short-circuit evaluation), so EXISTS is frequently lighter than a join.Using customers and orders, retrieve customers with order history (customer_id, name). Use EXISTS and do not duplicate rows.
| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| 4 | Suzuki |
| order_id | customer_id | amount |
|---|---|---|
| 101 | 1 | 5000 |
| 102 | 1 | 3000 |
| 103 | 2 | 8000 |
| 104 | 4 | 2000 |
| 105 | 4 | 4000 |
| 106 | 4 | 1000 |
Note: a direct INNER JOIN expands Suzuki (3 orders) to 3 rows. EXISTS is the correct approach; do not rely on DISTINCT.
| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 4 | Suzuki |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
The reverse of the previous question is finding customers who have never placed an order: an anti-join. There are three common ways to write it.
① NOT EXISTS (correlated and NULL-safe), ② LEFT JOIN ... WHERE right_key IS NULL (keep rows with no match as NULL rows), and ③ NOT IN (subquery). The third form has a famous trap: if the target column contains even one NULL, the result is always 0 rows.
-- ✗ NOT IN: one NULL in the subquery makes the result empty WHERE c.customer_id NOT IN (SELECT customer_id FROM orders) -- NULL in orders → 0 rows -- ✓ NOT EXISTS: correlated per-customer checks remain safe with NULL WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)
2 NOT IN (1, 4, NULL) becomes 2<>1 AND 2<>4 AND 2<>NULL. The final comparison, 2<>NULL, is UNKNOWN; once UNKNOWN enters an AND expression, the whole expression is UNKNOWN. SQL returns only TRUE rows, so no customer remains.Using customers and orders, retrieve customers who have never placed an order (customer_id, name). Use a NULL-safe NOT EXISTS approach.
| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| 4 | Suzuki |
| order_id | customer_id | product |
|---|---|---|
| 101 | 1 | Product A |
| 102 | 1 | Product B |
| 103 | 4 | Product C |
| 104 | NULL | Product D (guest purchase) |
Note: orders contains a guest purchase with customer_id = NULL. Writing NOT IN (SELECT customer_id FROM orders) would return 0 rows.
| customer_id | name |
|---|---|
| 2 | Sato |
| 3 | Yamada |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
Suppose we want one row per customer containing the counts of completed, cancelled, and pending orders plus the completed amount. Joining orders three times by status, or writing three subqueries, makes the query complex and slow and introduces the risk of join expansion.
With conditional aggregation, one GROUP BY folds N rows into 1 row while routing each condition into a separate output column (a pivot). PostgreSQL's aggregate_function FILTER (WHERE condition) is the most readable form; for portability, the equivalent is SUM(CASE WHEN ...).
-- FILTER: specify which rows an aggregate should include after the function (standard SQL) COUNT(*) FILTER (WHERE status = 'completed') AS completed_count SUM(amount) FILTER (WHERE status = 'completed') AS completed_amount -- For portability, use the equivalent CASE form SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END)
COUNT(*) FILTER(...) returns 0 when no rows qualify, while SUM(...) FILTER(...) returns NULL. Safely fill the amount with COALESCE(..., 0).Using customers and orders, retrieve one row per customer with completed count (completed_count), cancelled count (cancelled_count), pending count (pending_count), and completed amount total (completed_amount). Use conditional aggregation with FILTER.
| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | customer_id | status | amount |
|---|---|---|---|
| 101 | 1 | completed | 5000 |
| 102 | 1 | completed | 3000 |
| 103 | 1 | cancelled | 2000 |
| 104 | 2 | completed | 8000 |
| 105 | 2 | pending | 1000 |
| 106 | 3 | cancelled | 4000 |
| 107 | 3 | pending | 2000 |
| 108 | 3 | pending | 1500 |
Note: instead of joining three times by status, route the statuses into columns with one GROUP BY.
| name | completed_count | cancelled_count | pending_count | completed_amount |
|---|---|---|---|---|
| Tanaka | 2 | 1 | 0 | 8000 |
| Sato | 1 | 0 | 1 | 8000 |
| Yamada | 0 | 1 | 2 | 0 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
Suppose we want the 2 highest-priced products in each category. With a normal JOIN, a subquery in the FROM clause cannot reference columns from the outer table, so it cannot express a per-group restriction.
Adding LATERAL turns the subquery into a correlated join that can reference columns from each left-side row. This lets us write “run the subquery for each category, then apply ORDER BY ... LIMIT N inside it” to express Top-N per group concisely. Unlike ROW_NUMBER in Q7 of the earlier part, which returned one row per group (Top-1), this is a 1:N expansion controlled to any chosen N.
-- LATERAL: the subquery can reference columns from the left table c FROM categories c CROSS JOIN LATERAL ( SELECT ... FROM products p WHERE p.category_id = c.category_id -- ← Reference c from the left side (not allowed in a normal subquery) ORDER BY p.price DESC LIMIT 2 -- ← Limit to the top 2 in each category ) t
CROSS JOIN LATERAL drops a left row when its subquery returns 0 rows (a category with no products). If empty categories should remain, use LEFT JOIN LATERAL (...) ON true.Using categories and products, retrieve the 2 highest-priced products in each category (category_name, product_name, price, rank rn). Use a correlated join with LATERAL JOIN.
| category_id | category_name |
|---|---|
| 1 | Drinks |
| 2 | Food |
| product_id | category_id | product_name | price |
|---|---|---|---|
| 1 | 1 | Coffee | 500 |
| 2 | 1 | Tea | 450 |
| 3 | 1 | Juice | 400 |
| 4 | 1 | Water | 200 |
| 5 | 2 | Cake | 600 |
| 6 | 2 | Sandwich | 500 |
Note: Drinks has 4 products, but only the top 2 (Coffee and Tea) are returned. The point is to control each group's row count to N.
| category_name | product_name | price | rn |
|---|---|---|---|
| Drinks | Coffee | 500 | 1 |
| Drinks | Tea | 450 | 2 |
| Food | Cake | 600 | 1 |
| Food | Sandwich | 500 | 2 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
An organization chart where each employee points to a manager with manager_id is a self-referencing 1:N relationship. Because the number of levels is unknown, a fixed number of JOINs cannot traverse the entire hierarchy.
A recursive CTE (WITH RECURSIVE) ① creates an anchor, ② joins that result in a recursive term to generate the next level, and ③ repeats until no new rows appear. The key is that at every step, only the rows added in the current step become the next input (driving table).
WITH RECURSIVE org AS ( <anchor> -- Starting point (for example, the top-level president, level=1) UNION ALL <recursive term ... JOIN org> -- Join the previous result (org) to generate the next level ) -- Stop when the recursive term returns 0 rows
org referenced by the recursive term is not the full accumulated result; it is the rows newly generated in the immediately preceding step. The process goes president → direct reports → their reports, moving one level deeper from the latest additions.From employees, a self-referencing table where manager_id points to a manager, retrieve each employee's hierarchy level (level: top level = 1) and path (path: titles from the president joined with >). Use a recursive CTE.
| emp_id | name | manager_id |
|---|---|---|
| 1 | President | NULL |
| 2 | Manager A | 1 |
| 3 | Manager B | 1 |
| 4 | Section Chief A | 2 |
| 5 | Staff A | 4 |
Note: hierarchy depth is unknown. Use WITH RECURSIVE to expand every level one step at a time instead of using a fixed number of JOINs.
| emp_id | name | level | path |
|---|---|---|---|
| 1 | President | 1 | President |
| 2 | Manager A | 2 | President > Manager A |
| 3 | Manager B | 2 | President > Manager B |
| 4 | Section Chief A | 3 | President > Manager A > Section Chief A |
| 5 | Staff A | 4 | President > Manager A > Section Chief A > Staff A |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free