JOIN — Learn Multi-Table Joins from the Basics
BasicJOINMulti-table joinsWeb developmentPostgreSQL-ready5 questions
QUESTION 6
LEFT JOIN + Calendar Master — Build Time-Series Data Without Losing Zero-Sales Days
LEFT JOINMaster-data joinCOALESCEZero padding
Background

If you only GROUP BY sales data, a day with no sales disappears from the result, causing dates to disappear from the X-axis when you draw a graph.
To prevent this, use a calendar table containing every date in sequence as the main (left-side) table and LEFT JOIN the sales data to it.

FROM   calendar AS c
LEFT JOIN sales AS s ON c.cal_date = s.sale_date;
Using COALESCE: The aggregate result for a day with no sales becomes NULL, so the standard pattern is to use COALESCE(SUM(amount), 0) to convert it to 0.
Problem

There are a calendar table (calendar) containing dates from October 1 through October 3 and a sales table (sales).

Use the calendar table as the base, LEFT JOIN the sales, and calculate the sales total for each date (daily_sales). A date with no sales should become 0.

Tables used
▸ calendar
cal_date
10-01
10-02
10-03
▸ sales
sale_dateamount
10-01100
10-01150
10-03200
Expected Output
cal_datedaily_sales
10-01250
10-020
10-03200
Model Answer
SELECT
  c.cal_date,
  COALESCE(SUM(s.amount), 0) AS daily_sales  -- 0 on a day with no sales

FROM calendar AS c
LEFT JOIN sales AS s  -- keep days with no sales
  ON c.cal_date = s.sale_date
GROUP BY
  c.cal_date
ORDER BY
  c.cal_date;

/*
  Execution order:
  1. FROM calendar AS c    → the calendar
  2. LEFT JOIN sales AS s  → match by date
  3. GROUP BY c.cal_date   → group the expanded virtual table by calendar date
  4. SELECT ...             → aggregate with SUM
  5. ORDER BY c.cal_date   → sort by date
  */
Explanation (table transitions & key points)
SELECT c.cal_date, COALESCE(SUM(s.amount), 0) AS daily_sales FROM calendar AS c LEFT JOIN sales AS s ON c.cal_date = s.sale_date GROUP BY c.cal_date ORDER BY c.cal_date;
LEGEND
Rows read / loaded
① FROM
FROM calendar AS cRead the calendar table that provides the axis for preventing missing data.
1 / 5
cal_date
'10-01'
'10-02'
'10-03'
All 3 rows read
LEARNING POINTS
JOIN PATTERN
LEFT JOIN with a calendar master
use continuous dates as the axis to prevent gaps
c = calendar   s = sales
Generating continuous data (zero padding): In web dashboards and other graphs, eliminating missing dates at the data-source stage makes frontend processing dramatically simpler. The standard SQL technique for doing this is a LEFT JOIN with a calendar table.
How to build a calendar table: In practice, two common approaches are to create a physical table such as calendar_master containing roughly 10 years of dates, or to generate the dates dynamically inside the query with a function such as PostgreSQL's generate_series() (using a CTE).
ANTI-PATTERNS
Aggregate before joining: When combining GROUP BY and LEFT JOIN, it can be more performant to SUM by date in a subquery first and then join the result to the calendar, avoiding an explosion of rows. Check the execution plan as the data volume grows.
QUESTION 7
The LEFT JOIN Chain Trap — Prevent Unintended Data Loss from Join Order
LEFT JOININNER JOINJoin orderData loss
Background

When you connect several tables to a base table, you can accidentally attach an INNER JOIN after a LEFT JOIN that deliberately allowed NULL rows. The rows you took care to preserve then disappear.

-- ✗ Wrong chain (users without orders disappear)
FROM users LEFT JOIN orders ON ... INNER JOIN items ON ...

-- ✓ Correct chain (connect everything with LEFT JOIN)
FROM users LEFT JOIN orders ON ... LEFT JOIN items ON ...
Why do the rows disappear: LEFT JOIN orders creates a row where orders.item_id = NULL. During the next INNER JOIN items, the ON-clause evaluation treats it as having no matching item and excludes it.
Problem

There are three tables: users (users), orders (orders), and an item master (items).

Write the correct SQL to output every user's name, including users with no order history, together with the name of the item they ordered. When there is no order, the item name should be NULL.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Suzuki
▸ orders
user_iditem_id
110
220
▸ items
item_iditem_name
10PC
20Mouse
Expected Output
nameitem_name
TanakaPC
SatoMouse
SuzukiNULL
Model Answer
SELECT
  u.name,
  i.item_name

FROM users AS u
LEFT JOIN orders AS o        -- keep every user while joining orders (Suzuki becomes NULL)
  ON u.user_id = o.user_id
LEFT JOIN items AS i        -- LEFT JOIN the items too (INNER would remove Suzuki)
  ON o.item_id = i.item_id;

/*
  Execution order:
  1. FROM users AS u
  2. LEFT JOIN orders AS o
  3. LEFT JOIN items AS i        → join items to the virtual table
  4. SELECT u.name, i.item_name  → project the requested columns
  */
Explanation (table transitions & key points)
SELECT u.name, i.item_name FROM users AS u LEFT JOIN orders AS o ON u.user_id = o.user_id LEFT JOIN items AS i ON o.item_id = i.item_id;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead all users (3 rows) as the base table.
1 / 4
user_idname
1Tanaka
2Sato
3Suzuki
All 3 rows read
LEARNING POINTS
JOIN PATTERN
A chain of LEFT JOINs
connect every branch from the base table with LEFT JOIN
u = users   o = orders   i = items
Rows disappear because of join order: Even if you start with LEFT JOIN because you want every user in the result, a later INNER JOIN filters out the NULL-padded rows and makes them disappear. This is a classic cause of real-world bugs where the data count does not match expectations.
Make the design intent explicit: Once you choose the base table — the table you absolutely do not want to lose — a safe pattern is to connect its related tables with LEFT JOIN by default. Conversely, use INNER JOIN explicitly when the purpose of the join is filtering.
ANTI-PATTERNS
Filtering a LEFT JOINed table in WHERE: If you accidentally write a condition such as WHERE i.item_name = 'PC' on a table connected through a chain of LEFT JOINs, every NULL row — users who did not buy a PC — is removed, effectively making the behavior the same as an INNER JOIN (an implicit INNER JOIN). The condition belongs in ON i.item_name = 'PC'.
SELECT u.name, i.item_name FROM users AS u LEFT JOIN orders AS o ON u.user_id = o.user_id LEFT JOIN items AS i ON o.item_id = i.item_id WHERE i.item_name = 'PC';
LEGEND
Rows read / loaded
① After all LEFT JOINs
Immediately after every table has been LEFT JOINedLEFT JOIN leaves Suzuki's row in place, with NULL padding because she has no order.
1 / 2
nameitem_name
Tanaka'PC'
Sato'Mouse'
SuzukiNULL
3 rows
QUESTION 8
Latest-Record Extraction (Top-1 JOIN) — Use a Subquery's Aggregate to Retrieve the Detail Row
INNER JOINSubqueryComposite keyTop-N extraction
Background

It is common to retrieve every column from one specific row (Top-1) that has the latest date or highest score within each group, such as each user.
Because GROUP BY alone cannot retrieve columns other than the grouping key and aggregates, calculate the latest date in a subquery and INNER JOIN it back to the original table on a composite key to retrieve the complete record.

FROM logs AS a
INNER JOIN (
  SELECT id, MAX(date) AS max_d FROM logs GROUP BY id
) AS b
  ON a.id = b.id AND a.date = b.max_d;
Problem

There is a user login history table (login_history). The same user may log in multiple times.

Extract only each user's latest login record and retrieve user_id, the latest date (login_date), and the device used at that time (device).

Tables used
▸ login_history
user_idlogin_datedevice
110-01PC
110-05Smartphone
210-02PC
210-03PC
Expected Output
user_idlogin_datedevice
110-05Smartphone
210-03PC
Model Answer
SELECT
  h.user_id,
  h.login_date,
  h.device

FROM login_history AS h
INNER JOIN (
  -- subquery: calculate the latest date for each user
  SELECT user_id, MAX(login_date) AS max_date
  FROM login_history
  GROUP BY user_id
) AS latest
  ON  h.user_id = latest.user_id               -- join and extract only rows matching both user ID and date (= the latest row)
  AND h.login_date = latest.max_date;

/*
  Execution order:
  1. [Subquery] FROM login_history       → the table
  2. [Subquery] GROUP BY user_id         → calculate MAX date for each user
  3. FROM login_history AS h             → the main table
  4. INNER JOIN ... AS latest ON ...     → match the main rows with the subquery result on the composite key
  5. SELECT h.user_id, ...               → project the required columns
  */
Explanation (table transitions & key points)
SELECT h.user_id, h.login_date, h.device FROM login_history AS h INNER JOIN ( SELECT user_id, MAX(login_date) AS max_date FROM login_history GROUP BY user_id ) AS latest ON h.user_id = latest.user_id AND h.login_date = latest.max_date;
LEGEND
Rows read / loaded
① Subquery FROM
FROM login_historyFirst, read the login_history table (4 rows) inside the subquery.
1 / 6
user_idlogin_datedevice
1'10-01''PC'
1'10-05''Smartphone'
2'10-02''PC'
2'10-03''PC'
All 4 rows read
LEARNING POINTS
JOIN PATTERN
Top-1 JOIN (self-join subquery)
use aggregated keys to retrieve detail rows from the original table
h = history   sub = MAX date
Go beyond the limits of GROUP BY: If you write SELECT user_id, MAX(date), device ... GROUP BY user_id, SQL raises an error because device is not included in GROUP BY. The solution is to separate the aggregate phase from the detail-retrieval phase and connect them with a JOIN.
A common real-world scenario: Systems constantly need to retrieve one representative row with all its data for each group, such as the latest article in each blog category or each user's last order. Master this JOIN pattern as a fundamental technique.
FIELD NOTES
In modern SQL environments such as PostgreSQL and MySQL 8.0+, the current best practice for this Top-N extraction task is a window function (ROW_NUMBER()).
WITH ranked AS (SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date DESC) as rn FROM login_history) SELECT * FROM ranked WHERE rn = 1;
This removes the need for a JOIN and makes extensions such as “return the top 3” easy. Even so, understanding how JOIN works remains an important foundation.
QUESTION 9
Period-Overlap Check — Detect Double Bookings with a Non-Equi Self-Join
INNER JOINSelf-joinNon-equi joinPeriod overlap
Background

In a meeting-room booking system, combine a self-join with a non-equi join to detect whether two different bookings overlap in time.

The logical condition for two periods (Start–End) to overlap can be expressed in one line:
A.start < B.end AND A.end > B.start
Write this in the ON clause and JOIN the table to itself to extract only the overlapping pairs.

Problem

There is a meeting-room reservation table (reservations).

Find pairs of reservations whose times overlap (double bookings) in the same room, and output both reservation IDs (res_a, res_b) and the room name.

To avoid comparing a reservation with itself and avoid duplicate reverse-order pairs such as (A,B) and (B,A), also add the condition r1.res_id < r2.res_id.

Tables used
▸ reservations
res_idroom_namestart_tend_t
1Conference Room A10:0012:00
2Conference Room A11:0013:00
3Conference Room A13:0014:00
4Conference Room B10:0012:00
Expected Output
res_ares_broom_name
12Conference Room A
Model Answer
SELECT
  r1.res_id AS res_a,
  r2.res_id AS res_b,
  r1.room_name

FROM reservations AS r1
INNER JOIN reservations AS r2      -- join the table to itself (SELF JOIN)
  ON  r1.room_name = r2.room_name
  AND r1.res_id < r2.res_id        -- eliminate identical and reverse-order pairs
  AND r1.start_t < r2.end_t        -- logic for determining whether the periods overlap
  AND r1.end_t   > r2.start_t;

/*
  Execution order:
  1. FROM reservations AS r1        → read 4 rows as the base
  2. INNER JOIN reservations AS r2  → generate combinations and evaluate them in ON
  3. SELECT r1.res_id, ...          → project and output the one surviving pair
  */
Explanation (table transitions & key points)
SELECT r1.res_id AS res_a, r2.res_id AS res_b, r1.room_name FROM reservations AS r1 INNER JOIN reservations AS r2 ON r1.room_name = r2.room_name AND r1.res_id < r2.res_id AND r1.start_t < r2.end_t AND r1.end_t > r2.start_t;
LEGEND
Rows read / loaded
① FROM
FROM reservations AS r1Read the reservation table (4 rows) as the left-side base.
1 / 5
res_idroom_namestart_tend_t
1'Conference Room A''10:00''12:00'
2'Conference Room A''11:00''13:00'
3'Conference Room A''13:00''14:00'
4'Conference Room B''10:00''12:00'
All 4 rows read
LEARNING POINTS
JOIN PATTERN
Self-join + non-equi period join
cross-match the same table using inequality conditions
r1 = reservation (left)   r2 = reservation (right)
The logic of period overlap: If A ends after B starts and A starts before B ends, the two periods necessarily overlap. This is a general collision-detection algorithm, useful not only in databases but also in programming such as JavaScript (AABB and similar techniques).
The purpose of r1.res_id < r2.res_id: A self-join also evaluates identical rows such as r1=1 and r2=1; because their times obviously overlap, they would be output. The inequality also prevents the same result from appearing twice as (1,2) and (2,1), restricting evaluation to one direction for each distinct pair.
QUESTION 10
Previous-Day Comparison (Shifted Join) — Put the Previous Record Beside the Current One with a Self-Join
LEFT JOINSelf-joinShifted joinDay-over-day comparison
Background

For time-series data, when you need to compare across rows — such as yesterday's sales or the previous score — SQL can place the same table twice and shift a date or ID by -1 in the join condition.

FROM daily_kpi AS t1
-- join so that t2 becomes the record immediately before t1
LEFT JOIN daily_kpi AS t2
  ON t1.day_id - 1 = t2.day_id;
Problem

There is a table (daily_kpi) recording daily access counts. For clarity, it uses a sequential day_id rather than dates.

Self-join this table to place today's (today_users) and yesterday's (yesterday_users) access counts side by side, then calculate and output their difference (diff).
For a day with no previous day, such as day 1, yesterday_users and diff should be NULL.

Tables used
▸ daily_kpi
day_idusers
1100
2120
390
Expected Output
day_idtoday_usersyesterday_usersdiff
1100NULLNULL
212010020
390120-30
Model Answer
SELECT
  t1.day_id,
  t1.users AS today_users,
  t2.users AS yesterday_users,
  t1.users - t2.users AS diff

FROM daily_kpi AS t1
LEFT JOIN daily_kpi AS t2        -- keep today's row even when the previous day's data is missing
  ON t1.day_id - 1 = t2.day_id;  -- match t1's ID minus 1 to t2's yesterday ID

/*
  Execution order:
  1. FROM daily_kpi AS t1       → read 3 rows as t1 (the today axis)
  2. LEFT JOIN daily_kpi AS t2  → match each t1 row with the day_id reduced by 1
  3. SELECT ...                 → place the matched rows side by side, calculate diff, and output
  */
Explanation (table transitions & key points)
SELECT t1.day_id, t1.users AS today_users, t2.users AS yesterday_users, t1.users - t2.users AS diff FROM daily_kpi AS t1 LEFT JOIN daily_kpi AS t2 ON t1.day_id - 1 = t2.day_id;
LEGEND
Rows read / loaded
① FROM
FROM daily_kpi AS t1Read the daily_kpi table (3 rows) as the axis for today.
1 / 4
day_idusers
1100
2120
390
All 3 rows read
LEARNING POINTS
JOIN PATTERN
Formula-based shifted join (LEFT SELF JOIN)
deliberately shift records by -1 in the condition before joining
t1 = kpi (today)   t2 = kpi (yesterday)
A JOIN for putting data side by side: SQL is fundamentally good at processing rows vertically, but when you want to compare data from another row — such as yesterday's data — in the same row by subtraction, you need a JOIN to force the data side by side. This idea is a powerful tool when writing aggregate SQL.
Writing it with a real date type: This problem uses integer day_id, but with an actual PostgreSQL DATE column you would subtract a day with a date expression such as ON t1.date - INTERVAL '1 day' = t2.date.
FIELD NOTES
As with Top-1 extraction, the modern standard for retrieving the previous row's value is a window function such as LAG().
SELECT date, users, LAG(users) OVER(ORDER BY date) AS yesterday FROM daily_kpi;
This removes the need for a self-join. However, if there is a gap in the data — the 10/2 record itself does not exist — LAG() would subtract the 10/1 value, so combining it with the Q6 calendar JOIN is still necessary when you need the exact previous day.