Cardinality — Learn Table Relationships with LEFT JOIN, ROW_NUMBER, and CTEs from the Basics
BasicCardinalityLEFT JOINROW_NUMBERCTEPostgreSQL Compatible5 questions
QUESTION 6
The LEFT JOIN zero-count trap — The clear difference between COUNT(*) and COUNT(column)
LEFT JOIN1:NNULL handlingCOUNT function
Background

In Part 1 Q2, the INNER JOIN excluded Human Resources because it had zero employees. A LEFT JOIN removes this limitation and always keeps every row from the left table. When no row on the right matches, it automatically generates a row padded with NULLs.

-- INNER JOIN: a department with zero employees is excluded
FROM departments INNER JOIN employees ...  -- Human Resources: does not appear in the result

-- LEFT JOIN: keep departments with zero employees; the employee side is NULL
FROM departments LEFT JOIN employees ...   -- Human Resources: remains with emp_id=NULL
The most important difference between COUNT(*) and COUNT(column):
COUNT(*) counts the existence of rows. A row containing NULL columns still exists as one row, so a department with zero employees is incorrectly counted as 1.
COUNT(e.emp_id) returns the number of rows where that column is not NULL. It skips the NULL row and correctly returns 0.
Problem

LEFT JOIN departments and employees, and retrieve the number of employees per department (emp_count). Departments with zero employees must also be included.

Tables used
▸ departments
dept_iddept_name
1Sales
2Engineering
3Human Resources
▸ employees
emp_idnamedept_id
1Tanaka1
2Sato1
3Yamada2
4Suzuki2

Note: Human Resources (dept_id=3) has no employees. Using COUNT(*) would incorrectly count it as 1.

Expected Output
dept_nameemp_count
Sales2
Engineering2
Human Resources0
Model Answer
-- ✗ COUNT(*): counts the NULL row as 1 → Human Resources becomes 1 (wrong)
-- SELECT d.dept_name, COUNT(*) AS emp_count ...

SELECT
  d.dept_name,
  COUNT(e.emp_id) AS emp_count   -- skip NULL → Human Resources is 0 (correct)
FROM departments AS d
LEFT JOIN employees AS e
  ON d.dept_id = e.dept_id
GROUP BY d.dept_id, d.dept_name
ORDER BY d.dept_id;

/*
  Execution order:
  1. FROM departments AS d          → Read departments
  2. LEFT JOIN employees AS e       → Join (expanded 1:N)
  3. GROUP BY dept_id, dept_name    → Group rows
  4. COUNT(e.emp_id)                → Count while skipping NULL
  5. SELECT d.dept_name, emp_count  → Project two columns
  6. ORDER BY d.dept_id             → Sort and output
  */
Explanation (table transitions & key points)
SELECT d.dept_name, COUNT(e.emp_id) AS emp_count FROM departments AS d LEFT JOIN employees AS e ON d.dept_id = e.dept_id GROUP BY d.dept_id, d.dept_name ORDER BY d.dept_id;
LEGEND
Rows read / loaded
① Left table
FROM departments AS dRead the departments table (3 rows). Human Resources (dept_id=3) has no employees, but a LEFT JOIN keeps it in the result.
1 / 6
dept_iddept_name
1Sales
2Engineering
3Human Resources
All 3 rows read (left table — LEFT JOIN keeps every row)
LEARNING POINTS
NULL × COUNT
NULL rows after LEFT JOIN — how COUNT(*) and COUNT(column) behave differently
The pattern for correctly returning 0 for departments with no employees
LEFT JOIN → generate NULL → skip it with COUNT(e.emp_id)
The essence of COUNT(*) vs COUNT(column): COUNT(*) evaluates only whether the current row exists. A row with NULL columns still exists as one row, so it counts the NULL row generated by LEFT JOIN as 1. In contrast, COUNT(e.emp_id) returns the number of rows where e.emp_id is not NULL, skips the NULL row, and correctly returns 0. This distinction is one of the most common SQL aggregation mistakes.
How the row count changes after LEFT JOIN: when departments (3 rows) is the left table and employees (4 rows) is LEFT JOINed, the result has four matching employee rows plus one NULL row for Human Resources, for a total of 5 rows. Unlike INNER JOIN, a left-table row with no match is not removed; it is kept with NULLs. Make a habit of checking the row count before GROUP BY.
ANTI-PATTERNS
Convert NULL to 0 with COALESCE before COUNT: if you write COUNT(COALESCE(e.emp_id, 0)), COALESCE converts NULL to 0, so COUNT treats 0 as non-NULL and counts it as 1. The result is the same incorrect 1 as COUNT(*). To skip NULLs, use COUNT(e.emp_id) directly.
Use INNER JOIN when zero-count rows must be shown: INNER JOIN removes left-table rows that have no match on the right. To include departments with zero employees, LEFT JOIN is required. A requirement to include zero-count results is a signal to consider LEFT JOIN first.
Field Notes
Showing departments with zero employees as 0 is a very common dashboard and reporting requirement. Similar patterns include a sales report that includes products with no orders (products LEFT JOIN orders → COUNT(o.order_id)) and a survey summary that includes questions with zero answers (questions LEFT JOIN answers → COUNT(a.answer_id)). The difference between COUNT(*) and COUNT(column) does not exist for SUM in the same way (NULLs are skipped in SUM calculations), but it must always be kept in mind for COUNT.
QUESTION 7
Take only one row from the N side — Join the latest record with ROW_NUMBER()
Window functionCTE1:N→1:1ROW_NUMBER
Background

If you try to retrieve only the latest login for each user with a 1:N JOIN, the users rows expand by the number of rows in login_history.

The window function ROW_NUMBER() assigns a sequence number to every row in a group. Specify the group with PARTITION BY and the sort order with ORDER BY to select the newest row for each user, where rn=1.

-- ROW_NUMBER() syntax
ROW_NUMBER() OVER (
  PARTITION BY user_id         -- reset for each user
  ORDER BY login_at DESC      -- number newest first
) AS rn                        -- rn=1 is the latest login
Window functions vs GROUP BY: GROUP BY aggregates away rows, whereas a window function adds a column without removing rows. ROW_NUMBER keeps every row while adding ranking information, so a later JOIN can filter to only rn=1.
Problem

Join each user's latest login timestamp (last_login) to the users table. It is acceptable for last_login to be NULL when a user has no login history.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ login_history
log_iduser_idlogin_at
112024-03-10
212024-03-15
322024-03-08
422024-03-10
532024-03-12

Note: a direct JOIN would expand each users row by its number of logins. Use the correct approach: narrow the N side to one row with CTE + ROW_NUMBER before joining.

Expected Output
namelast_login
Tanaka2024-03-15
Sato2024-03-10
Yamada2024-03-12
Model Answer
WITH ranked_logins AS (
  SELECT
    user_id,
    login_at,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY login_at DESC   -- the newest row gets rn=1
    ) AS rn
  FROM login_history
)
SELECT
  u.name,
  r.login_at AS last_login
FROM users AS u
INNER JOIN ranked_logins AS r
  ON  u.user_id = r.user_id
  AND r.rn = 1              -- join only the latest row → becomes 1:1
ORDER BY u.user_id;

/*
  Execution order:
  1. CTE ranked_logins                → Read login_history
  2. ROW_NUMBER() OVER (...)          → Number each user's rows newest first
  3. FROM users AS u                  → Read users
  4. INNER JOIN ranked_logins (rn=1)  → Join only the latest rows
  5. SELECT u.name, r.login_at        → Project two columns
  6. ORDER BY u.user_id               → Sort and output
  */
Explanation (table transitions & key points)
WITH ranked_logins AS ( SELECT user_id, login_at, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_at DESC ) AS rn FROM login_history ) SELECT u.name, r.login_at AS last_login FROM users AS u INNER JOIN ranked_logins AS r ON u.user_id = r.user_id AND r.rn = 1 ORDER BY u.user_id;
LEGEND
Rows read / loaded
① CTE — source data
FROM login_historyRead the login_history table (5 rows). A direct JOIN would expand the users rows.
1 / 6
log_iduser_idlogin_at
112024-03-10
212024-03-15
322024-03-08
422024-03-10
532024-03-12
login_history: 5 rows
LEARNING POINTS
WINDOW FUNCTION
ROW_NUMBER + rn=1 — narrow the N side to one row and turn it into a 1:1 join
A modern pattern for safely taking only the latest row from an N-row history
login_history (N rows) → filter to rn=1 → 1:1 join to users
ROW_NUMBER() vs GROUP BY + MAX: GROUP BY + MAX(login_at) can retrieve the latest timestamp, but it cannot retrieve other columns from that same row, such as the login IP or device information, at the same time. The ROW_NUMBER pattern keeps every column of the selected row, making it much more useful in practice.
What the join condition AND r.rn = 1 means: adding AND r.rn = 1 to the JOIN condition makes only the rn=1 row (the latest login) for each user eligible to join. Rows with rn=2 or later do not join, so the post-JOIN row count stays equal to the users row count (3 rows). The reason N:1 expansion does not occur is the guarantee that rn=1 is exactly one row per user.
ANTI-PATTERNS
Confuse ROW_NUMBER with RANK: RANK() assigns the same number to tied rows. If two rows have exactly the same login_at, RANK gives both rows rn=1 and the JOIN expands to two rows. Use ROW_NUMBER when exactly one row is required.
Write WHERE r.rn = 1 in the outer SELECT: it is possible to avoid a CTE with FROM (SELECT ..., ROW_NUMBER() ... ) AS sub WHERE sub.rn = 1, but a CTE improves readability. The important point is to filter to rn=1 after calculating ROW_NUMBER. A window function cannot be used directly in a WHERE clause, so a subquery or CTE is required.
Field Notes
Taking only the latest, greatest, or smallest one row from each group is one of the most common patterns in practice. Examples include the latest order per customer (orders with ROW_NUMBER PARTITION BY customer_id ORDER BY ordered_at DESC), the latest price per product (price_history), and each employee's current position (position_history). It is especially powerful when you need multiple columns from that row, which GROUP BY + MAX cannot provide.
QUESTION 8
Pre-aggregation before a join — An elegant CTE solution for multiple 1:N joins
CTE (WITH clause)Avoiding fan trapsPre-aggregation1:N×2
Background

In Part 1 Q5, we avoided a fan trap with an inline subquery. Writing the same solution with a CTE (WITH clause) makes the query much easier to read. A CTE is a named subquery that is defined before the main query.

-- Basic CTE syntax: define aggregate tables before joining
WITH emp_agg AS (
  SELECT dept_id, SUM(salary) AS total_salary
  FROM employees
  GROUP BY dept_id             -- convert 1:N to 1:1
),
sales_agg AS (...)           -- chain multiple CTEs with commas
SELECT ... FROM departments
JOIN emp_agg ...               -- safe 1:1 × 1:1 join
Why CTEs are better than inline subqueries:
Readability: define the aggregation logic first, keeping the main query simple.
Reusability: reference the same CTE more than once.
Ease of debugging: SELECT from a CTE on its own to inspect an intermediate result.
Problem

Retrieve the total employee salary (total_salary) and total sales (total_sales) for each department. Use the correct approach: pre-aggregate each 1:N table with a CTE (WITH clause) before joining them.

Tables used
▸ departments
dept_iddept_name
1Sales
2Engineering
▸ employees
emp_iddept_idnamesalary
11Tanaka400000
21Sato350000
32Yamada500000
42Suzuki450000
▸ dept_sales
sale_iddept_idamount
11800000
21600000
321200000
42900000

Note: a direct JOIN creates an employees×dept_sales Cartesian product and doubles every value (the fan trap).

Expected Output
dept_nametotal_salarytotal_sales
Sales7500001400000
Engineering9500002100000
Model Answer
WITH emp_agg AS (
  SELECT dept_id, SUM(salary) AS total_salary
  FROM employees
  GROUP BY dept_id            -- convert 1:N → 1:1 (dept_id becomes unique)
),
sales_agg AS (
  SELECT dept_id, SUM(amount) AS total_sales
  FROM dept_sales
  GROUP BY dept_id            -- convert 1:N → 1:1 (dept_id becomes unique)
)
SELECT
  d.dept_name,
  e.total_salary,
  s.total_sales
FROM departments AS d
INNER JOIN emp_agg  AS e ON d.dept_id = e.dept_id  -- 1:1
INNER JOIN sales_agg AS s ON d.dept_id = s.dept_id  -- 1:1
ORDER BY d.dept_id;

/*
  Execution order:
  1. CTE emp_agg           → Aggregate employees (unique dept_id)
  2. CTE sales_agg         → Aggregate dept_sales (unique dept_id)
  3. FROM departments      → Read departments
  4. INNER JOIN emp_agg    → 1:1 join on dept_id
  5. INNER JOIN sales_agg  → 1:1 join on dept_id
  6. SELECT, ORDER BY      → Project columns and sort
  */
Explanation (table transitions & key points)
WITH emp_agg AS ( SELECT dept_id, SUM(salary) AS total_salary FROM employees GROUP BY dept_id ), sales_agg AS ( SELECT dept_id, SUM(amount) AS total_sales FROM dept_sales GROUP BY dept_id ) SELECT d.dept_name, e.total_salary, s.total_sales FROM departments AS d INNER JOIN emp_agg AS e ON d.dept_id = e.dept_id INNER JOIN sales_agg AS s ON d.dept_id = s.dept_id ORDER BY d.dept_id;
LEGEND
Rows read / loaded
① CTE 1 source data — employees
FROM employees (salary data)First inspect employees, a 1:N table (4 rows). It has multiple rows per department, so it must be aggregated by dept_id.
1 / 7
emp_iddept_idnamesalary
11Tanaka400000
21Sato350000
32Yamada500000
42Suzuki450000
employees: 4 rows
LEARNING POINTS
CTE PRE-AGGREGATION
Pre-aggregation with a WITH clause — rewrite Q5's inline subquery as a CTE
The idea is the same: aggregate first and convert to 1:1; the WITH clause improves readability
emp_agg = employee aggregate   sales_agg = dept_sales aggregate
The essential difference from Q5 — CTE vs inline subquery: Q5's inline subquery (a SELECT inside the FROM clause) and a CTE return the same result. The difference is only the order of expression and readability. A CTE declaratively states what to prepare first, making it especially effective when there are multiple aggregation steps. CTEs are the industry-standard style for large data-transformation queries.
Always align cardinality to 1:1 before JOIN: the core of this solution is still to aggregate before the JOIN so dept_id is unique. The principle does not change whether you use a CTE or an inline subquery. Whenever two 1:N tables are joined together, aggregate at least one of them first.
ANTI-PATTERNS
Try to “fix” the fan trap later with DISTINCT: removing duplicate rows with SELECT DISTINCT after a fan trap occurs does not repair the inflated and incorrect SUM or AVG values. DISTINCT removes duplicate rows, not double-counted numbers. The only correct solution is aggregation before the JOIN.
Nest too many CTEs: CTEs are convenient, but chaining ten or more can reduce readability. In some DBMSs, a CTE can also become an optimization boundary and affect performance. As a guideline, keep the number around three to five and consider a view or intermediate table when the query becomes too complex.
Field Notes
Producing department sales and labor costs in one query, or aggregating order counts and review counts per user, uses this same pattern. In data-mart construction and BI dashboard queries, a standard architecture is to pre-aggregate each dimension with a CTE and then join each fact set to the main table at 1:1 cardinality. Once you understand the fan trap from Q5 and learn the CTE form, you can write production queries that are both safe and readable.
QUESTION 9
The granularity mismatch trap — Avoid budget inflation when joining monthly budgets to daily sales
CTE (WITH clause)Aligning granularityDATE_TRUNCGROUP BY
Background

The granularity of a table describes what one row represents. In a monthly table, one row represents one department and one month; in a daily table, one row represents one department and one day.

When tables with different granularities are joined directly, the coarser-grained value is copied once for every finer-grained row. Joining a monthly budget (one row per month) to three daily sales rows copies the budget three times.

-- ✗ Direct JOIN → monthly_budget.budget is copied for every daily_sales row
FROM monthly_budget AS b
JOIN daily_sales    AS d ON b.dept_id = d.dept_id
                              AND b.month = DATE_TRUNC('month', d.sale_date)
-- dept_id=1: budget=500000 copied for 3 days → SUM(budget) = 1500000 (3x!)
Principle of aligning granularity: before joining tables with different granularities, aggregate the finer-grained table to the coarser granularity so they match. For daily-to-monthly aggregation, use DATE_TRUNC('month', date_column) + GROUP BY.
Problem

Using monthly_budget (monthly budgets) and daily_sales (daily sales), retrieve the budget (budget), monthly sales total (total_revenue), and budget achievement rate (achievement_rate: %, rounded to one decimal place) for each department and month.

Tables used
▸ monthly_budget
monthdept_idbudget
2024-03-011500000
2024-03-012800000
▸ daily_sales
sale_datedept_idrevenue
2024-03-051100000
2024-03-121200000
2024-03-20180000
2024-03-082250000
2024-03-152300000
2024-03-222200000

Note: a direct JOIN inflates monthly_budget.budget by the number of daily rows (threefold). Use a CTE to aggregate daily data to monthly granularity before joining.

Expected Output
monthdept_idbudgettotal_revenueachievement_rate
2024-03-01150000038000076.0
2024-03-01280000075000093.8
Model Answer
WITH monthly_sales AS (
  SELECT
    DATE_TRUNC('month', sale_date)::DATE AS month,  -- convert daily data to monthly granularity
    dept_id,
    SUM(revenue)                              AS total_revenue
  FROM daily_sales
  GROUP BY DATE_TRUNC('month', sale_date)::DATE, dept_id
)
SELECT
  b.month,
  b.dept_id,
  b.budget,
  ms.total_revenue,
  ROUND(ms.total_revenue::NUMERIC / b.budget * 100, 1) AS achievement_rate
FROM monthly_budget AS b
LEFT JOIN monthly_sales AS ms
  ON  b.month   = ms.month
  AND b.dept_id = ms.dept_id
ORDER BY b.month, b.dept_id;

/*
  Execution order:
  1. CTE monthly_sales              → Read daily_sales
  2. DATE_TRUNC('month', ...)       → Round dates down to the first of the month
  3. GROUP BY month, dept_id        → Group by month × department
  4. SUM(revenue)                   → Aggregate monthly sales
  5. FROM monthly_budget AS b       → Read budgets
  6. LEFT JOIN monthly_sales AS ms  → Join on month + dept_id
  7. ROUND(...)                     → Calculate the achievement rate (%)
  8. SELECT, ORDER BY               → Project columns and sort
  */
Explanation (table transitions & key points)
WITH monthly_sales AS ( SELECT DATE_TRUNC('month', sale_date)::DATE AS month, dept_id, SUM(revenue) AS total_revenue FROM daily_sales GROUP BY DATE_TRUNC('month', sale_date)::DATE, dept_id ) SELECT b.month, b.dept_id, b.budget, ms.total_revenue, ROUND(ms.total_revenue::NUMERIC / b.budget * 100, 1) AS achievement_rate FROM monthly_budget AS b LEFT JOIN monthly_sales AS ms ON b.month = ms.month AND b.dept_id = ms.dept_id ORDER BY b.month, b.dept_id;
LEGEND
Rows read / loaded
① CTE source data — daily_sales
FROM daily_sales (daily granularity)This daily-grain table records multiple sales dates for each department (one row = one department and one day). Aggregate it to match the monthly grain of the budget table.
1 / 7
sale_datedept_idrevenue
2024-03-051100000
2024-03-121200000
2024-03-20180000
2024-03-082250000
2024-03-152300000
2024-03-222200000
daily_sales: 6 rows
LEARNING POINTS
GRANULARITY
Align granularity — aggregate daily data to monthly before joining
Aligning what one row represents is the key to preventing JOIN mistakes
monthly_budget (monthly grain) ← CTE → monthly_sales (monthly grain)
What granularity means: a table's granularity is the fineness of the event represented by one row. monthly_budget has one row per department and month (monthly grain), while daily_sales has one row per department and day (daily grain). When tables with different grains are joined, the coarser-grained value is copied for every finer-grained row. Check the grain before joining and aggregate when necessary to align it.
What DATE_TRUNC does — round a date to month: DATE_TRUNC('month', sale_date) rounds a date down to the first day of its month. 2024-03-05, 2024-03-12, and 2024-03-20 all become 2024-03-01. This lets different dates in the same month belong to one GROUP BY group. Using it as a GROUP BY key converts daily granularity to monthly granularity.
ANTI-PATTERNS
Join without checking granularity: if you do not check what one row in each table represents, budget inflation like this can occur. Before writing a JOIN, always check each table's grain (its primary or unique key). Knowing which columns form the primary key reveals the grain; monthly_budget has the composite primary key (month, dept_id).
Make the daily table “more monthly” in the wrong direction: assigning monthly budget to daily rows by dividing it by the number of days and then joining is backwards. The correct direction is to aggregate the daily table to monthly (fine to coarse). Do not match the smallest analysis grain; match the grain you want to compare by rolling the finer table up to it.
Field Notes
Granularity problems are especially common when connecting BI tools such as Looker or Tableau and when designing data warehouses. Typical examples include monthly KPI reports that join a monthly target table to daily logs, and weekly summaries that aggregate daily data with DATE_TRUNC('week'). Granularity must also be managed when joining a DateSpine (date sequence table). In practice, if an aggregation query feels slow or its values look wrong, make checking the grain your first suspicion.
QUESTION 10
The N:N AND-search trap — Relational division with an intermediate table
IN + GROUP BYN:NHAVINGRelational division
Background

When looking for users who bought both Coffee and Cake through an N:N junction table, it is tempting to write WHERE product = 'A' AND product = 'B'. But this always returns zero rows.

-- ✗ AND conditions apply to the same row
WHERE p.product_name = 'Coffee'
  AND p.product_name = 'Cake'
-- one product_name cannot hold two values at once → always 0 rows

-- ✓ narrow candidate rows with IN, then test “has both” with HAVING
WHERE p.product_name IN ('Coffee', 'Cake')   -- retrieve candidate rows with OR
HAVING COUNT(DISTINCT p.product_name) = 2   -- keep groups containing two products
SQL's set-oriented way of thinking: one row can contain only one value at a time. To evaluate “A and B” across multiple rows, first line the rows up vertically with IN, then aggregate across the group horizontally with HAVING. This change of perspective is characteristic of relational databases.
Problem

Using the users and purchases tables, retrieve users who bought both “Coffee” and “Cake”.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
4Suzuki
▸ purchases
purchase_iduser_idproduct_name
11Coffee
21Cake
32Coffee
42Sandwich
53Cake
63Sandwich
74Coffee
84Cake

Note: WHERE product_name = 'Coffee' AND product_name = 'Cake' returns zero rows. Use IN + GROUP BY + HAVING COUNT(DISTINCT ...).

Expected Output
user_idname
1Tanaka
4Suzuki
Model Answer
-- ✗ AND: one row cannot be both “Coffee” and “Cake” → 0 rows
-- WHERE p.product_name = 'Coffee' AND p.product_name = 'Cake'

SELECT
  u.user_id,
  u.name
FROM users AS u
INNER JOIN purchases AS p
  ON u.user_id = p.user_id
WHERE p.product_name IN ('Coffee', 'Cake')  -- first narrow candidate rows with OR
GROUP BY u.user_id, u.name
HAVING COUNT(DISTINCT p.product_name) = 2  -- keep only groups with both products
ORDER BY u.user_id;

/*
  Execution order:
  1. FROM users AS u                      → Read users
  2. INNER JOIN purchases AS p            → Join (expanded 1:N)
  3. WHERE product_name IN (...)          → Keep target products
  4. GROUP BY u.user_id, u.name           → Group by user
  5. HAVING COUNT(DISTINCT product_name)  → Keep users with both products
  6. SELECT u.user_id, u.name             → Project two columns
  7. ORDER BY u.user_id                   → Sort and output
  */
Explanation (table transitions & key points)
SELECT u.user_id, u.name FROM users AS u INNER JOIN purchases AS p ON u.user_id = p.user_id WHERE p.product_name IN ('Coffee', 'Cake') GROUP BY u.user_id, u.name HAVING COUNT(DISTINCT p.product_name) = 2 ORDER BY u.user_id;
LEGEND
Rows read / loaded
① Target data (immediately after JOIN)
INNER JOIN users AS u ON u.user_id = p.user_idThis is the state immediately after joining users and purchases by user_id. The 1:N relationship expands each user by the number of purchase rows. We will now apply the WHERE clause to this 8-row table.
1 / 6
user_idnameproduct_name
1TanakaCoffee
1TanakaCake
2SatoCoffee
2SatoSandwich
3YamadaCake
3YamadaSandwich
4SuzukiCoffee
4SuzukiCake
After JOIN: 8 rows (1:N expansion)
LEARNING POINTS
RELATIONAL DIVISION
Relational division — evaluate vertical data against horizontal conditions with SQL set operations
The correct AND search: expand rows vertically with IN → evaluate across groups with HAVING
WHERE IN → GROUP BY → HAVING COUNT(DISTINCT) = N
Evaluation unit of an AND condition — why it returns zero rows: the AND condition in WHERE is evaluated simultaneously against one row. A product_name has only one value, so no row can be both Coffee and Cake. This is normal SQL behavior, not a bug. To express an AND across multiple rows, change the approach.
How IN + GROUP BY + HAVING implements relational division: first gather target-product rows vertically with IN ('Coffee', 'Cake') (an OR condition), then group them by user with GROUP BY user_id, and finally keep only users whose group contains two distinct products with HAVING COUNT(DISTINCT product_name) = 2. Expand vertically by row, then evaluate horizontally by group is the essence of SQL-style set operations.
ANTI-PATTERNS
Use the wrong N in COUNT(DISTINCT): the “2” in HAVING COUNT(DISTINCT product_name) = 2 is the number of target products. To find users who bought all three products (Coffee, Cake, and Sandwich), use = 3. Always make N equal to the number of elements in IN. If the number of elements changes dynamically, write = (SELECT COUNT(DISTINCT ...) FROM ...) with a subquery.
Use the old self-join approach: a self-join such as purchases AS a JOIN purchases AS b ON a.user_id = b.user_id WHERE a.product='Coffee' AND b.product='Cake' can produce the same result, but adding a third or fourth target product makes the number of joins grow and the query explosively complex. The IN + HAVING COUNT(DISTINCT) pattern scales much better.
Field Notes
This pattern is called relational division, an operation rooted in set theory and characteristic of relational databases. Practical applications include finding products that have every specified tag (product_tags with IN + HAVING), candidates who have all required skills (candidate_skills), and users who have every specified permission (user_roles). Making N dynamic generalizes the query to find every row that satisfies all conditions.