Cardinality — Learn Relationships with EXISTS, LATERAL, and Recursive CTEs in Practice
AdvancedCardinalityEXISTSLATERALRecursive CTEPostgreSQL Compatible5 questions
QUESTION 1
Semi-Join with EXISTS — Check for ‘has activity’ without increasing rows with JOIN
EXISTSSemi-JoinCorrelated subqueryRow-count preservation
Background

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)
Why SELECT 1: EXISTS asks only whether a row exists, so the selected expression can be anything; 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.
Problem

Using customers and orders, retrieve customers with order history (customer_id, name). Use EXISTS and do not duplicate rows.

Tables used
▸ customers
customer_idname
1Tanaka
2Sato
3Yamada
4Suzuki
▸ orders
order_idcustomer_idamount
10115000
10213000
10328000
10442000
10544000
10641000

Note: a direct INNER JOIN expands Suzuki (3 orders) to 3 rows. EXISTS is the correct approach; do not rely on DISTINCT.

Expected Output
customer_idname
1Tanaka
2Sato
4Suzuki
QUESTION 2
Anti-Join with NOT EXISTS and the NOT IN NULL Trap — Customers with no orders
NOT EXISTSAnti-JoinNOT INThree-valued NULL logic
Background

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)
Why NOT IN is destroyed by NULL: 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.
Problem

Using customers and orders, retrieve customers who have never placed an order (customer_id, name). Use a NULL-safe NOT EXISTS approach.

Tables used
▸ customers
customer_idname
1Tanaka
2Sato
3Yamada
4Suzuki
▸ orders
order_idcustomer_idproduct
1011Product A
1021Product B
1034Product C
104NULLProduct 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.

Expected Output
customer_idname
2Sato
3Yamada
QUESTION 3
Conditional Aggregation (FILTER / CASE) — Aggregate multiple metrics in one row without repeated JOINs
Conditional aggregationFILTER / CASEPivotN:1 aggregation
Background

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 and SUM when there are 0 matching rows: COUNT(*) FILTER(...) returns 0 when no rows qualify, while SUM(...) FILTER(...) returns NULL. Safely fill the amount with COALESCE(..., 0).
Problem

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.

Tables used
▸ customers
customer_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_idcustomer_idstatusamount
1011completed5000
1021completed3000
1031cancelled2000
1042completed8000
1052pending1000
1063cancelled4000
1073pending2000
1083pending1500

Note: instead of joining three times by status, route the statuses into columns with one GROUP BY.

Expected Output
namecompleted_countcancelled_countpending_countcompleted_amount
Tanaka2108000
Sato1018000
Yamada0120
QUESTION 4
Top-N per Group with LATERAL JOIN — Controlled 1:N expansion through correlated joins
LATERALCorrelated joinTop-N per groupControlled 1:N
Background

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
Choosing between CROSS and LEFT: 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.
Problem

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.

Tables used
▸ categories
category_idcategory_name
1Drinks
2Food
▸ products
product_idcategory_idproduct_nameprice
11Coffee500
21Tea450
31Juice400
41Water200
52Cake600
62Sandwich500

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.

Expected Output
category_nameproduct_namepricern
DrinksCoffee5001
DrinksTea4502
FoodCake6001
FoodSandwich5002
QUESTION 5
Expand Hierarchies with a Recursive CTE — Traverse a self-referencing 1:N organization chart one level at a time
Recursive CTEWITH RECURSIVESelf-referencing 1:NHierarchy expansion
Background

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
New rows drive the next step: the 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.
Problem

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.

Tables used
▸ employees
emp_idnamemanager_id
1PresidentNULL
2Manager A1
3Manager B1
4Section Chief A2
5Staff A4

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.

Expected Output
emp_idnamelevelpath
1President1President
2Manager A2President > Manager A
3Manager B2President > Manager B
4Section Chief A3President > Manager A > Section Chief A
5Staff A4President > Manager A > Section Chief A > Staff A