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;
NULL, so the standard pattern is to use COALESCE(SUM(amount), 0) to convert it to 0.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.
| cal_date |
|---|
| 10-01 |
| 10-02 |
| 10-03 |
| sale_date | amount |
|---|---|
| 10-01 | 100 |
| 10-01 | 150 |
| 10-03 | 200 |
| cal_date | daily_sales |
|---|---|
| 10-01 | 250 |
| 10-02 | 0 |
| 10-03 | 200 |
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 */
LEGEND
① FROM
FROM calendar AS cRead the calendar table that provides the axis for preventing missing data.| cal_date |
|---|
| '10-01' |
| '10-02' |
| '10-03' |
c = calendar s = sales
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).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.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 ...
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.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.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Suzuki |
| user_id | item_id |
|---|---|
| 1 | 10 |
| 2 | 20 |
| item_id | item_name |
|---|---|
| 10 | PC |
| 20 | Mouse |
| name | item_name |
|---|---|
| Tanaka | PC |
| Sato | Mouse |
| Suzuki | NULL |
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 */
LEGEND
① FROM
FROM users AS uRead all users (3 rows) as the base table.| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Suzuki |
u = users o = orders i = items
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.LEFT JOIN by default. Conversely, use INNER JOIN explicitly when the purpose of the join is filtering.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'.LEGEND
① 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.| name | item_name |
|---|---|
| Tanaka | 'PC' |
| Sato | 'Mouse' |
| Suzuki | NULL |
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;
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).
| user_id | login_date | device |
|---|---|---|
| 1 | 10-01 | PC |
| 1 | 10-05 | Smartphone |
| 2 | 10-02 | PC |
| 2 | 10-03 | PC |
| user_id | login_date | device |
|---|---|---|
| 1 | 10-05 | Smartphone |
| 2 | 10-03 | PC |
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 */
LEGEND
① Subquery FROM
FROM login_historyFirst, read the login_history table (4 rows) inside the subquery.| user_id | login_date | device |
|---|---|---|
| 1 | '10-01' | 'PC' |
| 1 | '10-05' | 'Smartphone' |
| 2 | '10-02' | 'PC' |
| 2 | '10-03' | 'PC' |
h = history sub = MAX date
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.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.
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.
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.
| res_id | room_name | start_t | end_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 |
| res_a | res_b | room_name |
|---|---|---|
| 1 | 2 | Conference Room A |
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 */
LEGEND
① FROM
FROM reservations AS r1Read the reservation table (4 rows) as the left-side base.| res_id | room_name | start_t | end_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' |
r1 = reservation (left) r2 = reservation (right)
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.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;
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.
| day_id | users |
|---|---|
| 1 | 100 |
| 2 | 120 |
| 3 | 90 |
| day_id | today_users | yesterday_users | diff |
|---|---|---|---|
| 1 | 100 | NULL | NULL |
| 2 | 120 | 100 | 20 |
| 3 | 90 | 120 | -30 |
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 */
LEGEND
① FROM
FROM daily_kpi AS t1Read the daily_kpi table (3 rows) as the axis for today.| day_id | users |
|---|---|
| 1 | 100 |
| 2 | 120 |
| 3 | 90 |
t1 = kpi (today) t2 = kpi (yesterday)
DATE column you would subtract a day with a date expression such as ON t1.date - INTERVAL '1 day' = t2.date.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.