JOIN — Learn Complex Join Patterns in Practice
AdvancedAdvanced JOINComplex joinsWeb developmentPostgreSQL-ready5 questions
QUESTION 6
FULL OUTER JOIN — Reconcile Two Datasets Completely While Preserving Missing Rows on Both Sides
FULL OUTER JOINCOALESCEData reconciliationMissing on either side
Background

LEFT JOIN keeps every row from the left table, and RIGHT JOIN keeps every row from the right table. FULL OUTER JOIN keeps every row from both tables, so no row is missed. A row that exists on only one side is padded with NULL on the other side.

-- Comparing join types (left: A={1,2,3}, right: B={1,3,4})
LEFT  JOIN → 1, 2, 3       (2 is absent from B, so the B side is NULL)
RIGHT JOIN → 1, 3, 4       (4 is absent from A, so the A side is NULL)
FULL  JOIN → 1, 2, 3, 4    (B is NULL for 2; A is NULL for 4)
Use COALESCE to fill NULLs from both sides: FULL JOIN can produce NULLs on either side. For columns such as IDs or names that may exist in either table, write both sides as COALESCE(lm.col, tm.col) to fill the NULL.
Not supported by MySQL: MySQL does not support FULL OUTER JOIN. Use LEFT JOIN … UNION ALL … RIGHT JOIN WHERE A.id IS NULL as an alternative. PostgreSQL, BigQuery, and SQL Server support it.
Problem

An e-commerce site has product-sales tables for last month (last_month) and this month (this_month). Some products sold only last month, some only this month, and some in both months.

Cover every product and place last month's sales (last_amount) next to this month's sales (this_amount). A product missing from one month should have NULL for that month.

Tables used
▸ last_month
product_idproduct_nameamount
1PC500
2Mouse120
3Keyboard280
▸ this_month
product_idproduct_nameamount
1PC630
3Keyboard210
4Monitor450
Expected Output
product_idproduct_namelast_amountthis_amount
1PC500630
2Mouse120NULL
3Keyboard280210
4MonitorNULL450
QUESTION 7
Anti-JOIN — Use LEFT JOIN + WHERE IS NULL to Filter for Non-Existence
LEFT JOINAnti-JOINSet differenceNOT EXISTS
Background

The operation of retrieving “rows that exist in table A but not in table B” is called an anti-JOIN (set difference). It is common in practical work for detecting churned users, finding unprocessed tickets, and identifying data that is missing from a master table.

There are two main ways to implement it.

-- ① LEFT JOIN + WHERE IS NULL (the method used in this question)
SELECT a.* FROM A LEFT JOIN B ON a.id = b.id
WHERE b.id IS NULL;

-- ② NOT EXISTS (more explicit intent)
SELECT a.* FROM A
WHERE NOT EXISTS (SELECT 1 FROM B WHERE b.id = a.id);
Do not use NOT IN: If the subquery result for WHERE a.id NOT IN (SELECT id FROM B) contains even one NULL, every row becomes UNKNOWN and the result is empty. Use LEFT JOIN or NOT EXISTS for an anti-JOIN.
Problem

There are tables of customers who purchased last month (last_buyers) and customers who purchased this month (this_buyers).

Retrieve the user_id and user_name of customers who purchased last month but not this month (churned customers).

Tables used
▸ last_buyers
user_iduser_name
1Tanaka
2Sato
3Suzuki
4Takahashi
▸ this_buyers
user_id
1
3
Expected Output
user_iduser_name
2Sato
4Takahashi
QUESTION 8
Many-to-Many (M:N) JOIN — Traverse a Junction Table in Two Steps to Search by Tag
INNER JOINMany-to-manyJunction tableTag system
Background

A many-to-many (M:N) relationship—where one article can have multiple tags and one tag can belong to multiple articles—is modeled with a junction table containing two foreign keys. This is one of the most common structures in web-application database design.

-- A typical many-to-many database structure
articles      (article_id, title)       ← entity A
tags          (tag_id, tag_name)        ← entity B
article_tags  (article_id, tag_id)     ← junction table (bridge)

-- Retrieval pattern: traverse articles → article_tags → tags in two steps
FROM articles AS a
INNER JOIN article_tags AS at ON a.article_id = at.article_id
INNER JOIN tags         AS t  ON at.tag_id    = t.tag_id
When DISTINCT is needed: If an article has multiple target tags, the same article expands into multiple rows. When you need only an article list, the standard approach is to remove duplicates with SELECT DISTINCT.
Problem

There are an article table (articles), a tag table (tags), and a junction table (article_tags) that links them.

Retrieve the article_id and title of articles tagged with the tag name 'React'.

Tables used
▸ articles
article_idtitle
1Build an SPA with React
2Complete Guide to SQL JOIN
3Compare Vue and React
▸ tags
tag_idtag_name
1React
2SQL
3Vue
▸ article_tags (junction table)
article_idtag_id
11
22
31
33
Expected Output
article_idtitle
1Build an SPA with React
3Compare Vue and React
QUESTION 9
CTE + JOIN — Build a Virtual Table Step by Step with WITH to Implement Cohort Analysis
CTE / WITHLEFT JOINCohort analysisMulti-stage aggregation
Background

Packing a complex aggregation into one SELECT statement causes nested subqueries to pile up, sharply reducing readability and maintainability. A WITH clause (CTE: Common Table Expression) lets you break the aggregation into named virtual tables step by step, then JOIN them in the main query.

-- Basic CTE syntax
WITH cte_name AS (
  SELECT ...          -- write the aggregation query here
  FROM   source_table
  GROUP BY ...
)
SELECT *
FROM   main_table
LEFT JOIN cte_name ON ...;  -- a CTE can be joined as a table
Put date calculations in the ON clause: A LEFT JOIN ON clause can contain calculated expressions, not only equality conditions, such as fo.first_order_date <= u.register_date + INTERVAL '30 days'. Putting the condition in ON rather than WHERE applies it without removing rows from the left side of the LEFT JOIN.
Problem

There are tables for user registrations (users) and order history (orders). A user may place multiple orders.

For each registration month (cohort month), count users who made their first purchase within 30 days of registration (converted_users) and all registered users (total_users).

Tables used
▸ users
user_idregister_date
12024-04-01
22024-04-20
32024-05-05
42024-05-10
▸ orders
order_iduser_idorder_date
10112024-04-10
10212024-06-01
10322024-07-01
10432024-05-15
Expected Output
cohort_monthtotal_usersconverted_users
2024-0421
2024-0521
QUESTION 10
Relational-Division-Style JOIN — Join the Same Table Multiple Times to Find Rows That Satisfy A AND B
INNER JOINSelf-joinRelational divisionMultiple-condition AND
Background

The problem “find users who have purchased both product A and product B” cannot be solved with a simple WHERE … IN. Each row represents only one purchase, so asking one row to be product A and product B at the same time is contradictory.

There are two solutions.

-- ① SELF JOIN the same table twice to place p1=product A and p2=product B side by side
FROM purchases AS p1
INNER JOIN purchases AS p2
  ON  p1.user_id = p2.user_id
WHERE p1.product_code = 'A' AND p2.product_code = 'B'

-- ② Aggregate first and filter with HAVING (alternative; useful with many products)
SELECT user_id FROM purchases
WHERE  product_code IN ('A', 'B')
GROUP BY user_id
HAVING COUNT(DISTINCT product_code) = 2
Relational division: The operation of finding groups that satisfy every specified requirement corresponds to “division” in relational algebra. If the number of products grows to three or more, add JOINs or change the condition to something like HAVING COUNT = 3.
Problem

There is a purchase-history table (purchases). Each row records one purchase of a specific product by a user.

Retrieve the user_id of users who have purchased both product code 'A' and product code 'B'.

Tables used
▸ purchases
user_idproduct_code
1A
1B
1C
2A
3B
4A
4B
Expected Output
user_id
1
4