JOINs: Combining Tables
Concept. A JOIN pairs rows from two tables on a predicate. The join type (INNER, LEFT, RIGHT, FULL) decides what happens to rows that find no match.
Intuition. INNER JOIN of Users and Listens on user_id returns one row per matching pair, so Mickey's 3 listens become 3 rows. LEFT JOIN keeps every user even if they have no listens; INNER drops them.
Note on ordering: The visual examples show one possible ordering. Without ORDER BY, your results may appear in any order, even different each time you run the query.
Figure 1. User names live in the Users table; every play lives in a separate Listens table. A join pairs each Users row with its matching Listens rows on user_id. It does not intersect the tables. Mickey has 3 rows in Listens, so the join produces 3 Mickey rows, one per play. That is the ×3. INNER JOIN keeps only matched pairs, so it drops Pluto, who has zero plays: 9 rows.
INNER JOIN
Users Table (4 rows)
| user_id | name | ||
|---|---|---|---|
| 1 | Mickey | mickey@example.com | |
| 2 | Minnie | minnie@example.com | |
| 3 | Daffy | daffy@example.com | |
| 4 | Pluto | pluto@example.com |
Listens Table (9 rows)
| listen_id | user_id | song_id | rating | listen_time | |
|---|---|---|---|---|---|
| 1 | 1 | 1 | 4.5 | 2024-08-30 14:35:00 | |
| 2 | 1 | 2 | 4.2 | NULL | |
| 3 | 1 | 6 | 3.9 | 2024-08-29 10:15:00 | |
| 4 | 2 | 2 | 4.7 | NULL | |
| 5 | 2 | 7 | 4.6 | 2024-08-28 09:20:00 | |
| 6 | 2 | 8 | 3.9 | 2024-08-27 16:45:00 | |
| 7 | 3 | 1 | 2.9 | NULL | |
| 8 | 3 | 2 | 4.9 | 2024-08-26 12:30:00 | |
| 9 | 3 | 6 | NULL | NULL |
-- Get all users with their listening history (excludes users without listens)
SELECT u.user_id, u.name, l.listen_id, l.song_id, l.rating
FROM Users u
INNER JOIN Listens l
ON u.user_id = l.user_id
ORDER BY u.user_id, l.listen_id;
Output (9 rows)
| user_id | name | listen_id | song_id | rating | |
|---|---|---|---|---|---|
| 1 | Mickey | 1 | 1 | 4.5 | |
| 1 | Mickey | 2 | 2 | 4.2 | |
| 1 | Mickey | 3 | 6 | 3.9 | |
| 2 | Minnie | 4 | 2 | 4.7 | |
| 2 | Minnie | 5 | 7 | 4.6 | |
| 2 | Minnie | 6 | 8 | 3.9 | |
| 3 | Daffy | 7 | 1 | 2.9 | |
| 3 | Daffy | 8 | 2 | 4.9 | |
| 3 | Daffy | 9 | 6 | NULL |
- INNER JOIN is a double for-loop: for each user, for each of their listens, emit one row. Unmatched users (Pluto) drop out.
Multiple JOINs
-- Get user + song details for high-rated listens (rating > 4.0)
SELECT u.name, s.title, s.artist, l.rating
FROM Users u
INNER JOIN Listens l ON u.user_id = l.user_id
INNER JOIN Songs s ON l.song_id = s.song_id
WHERE l.rating > 4.0
ORDER BY u.name, l.rating DESC;
| name | title | artist | rating | |
|---|---|---|---|---|
| Daffy | Willow | Taylor Swift | 4.9 | |
| Mickey | Evermore | Taylor Swift | 4.5 | |
| Mickey | Willow | Taylor Swift | 4.2 | |
| Minnie | Willow | Taylor Swift | 4.7 | |
| Minnie | Yellow Sub | The Beatles | 4.6 |
-
Connect Users, Listens, and Songs.
-
Apply WHERE after all JOINs.
-
Chain joins to walk from Users to Songs through Listens; the WHERE filter runs once, after every join is built.
Outer Joins: LEFT and RIGHT
LEFT JOIN
-- Keep every user, even those with no listens
SELECT u.user_id, u.name, l.listen_id, l.rating
FROM Users u
LEFT JOIN Listens l
ON u.user_id = l.user_id
ORDER BY u.user_id, l.listen_id;
LEFT keeps every row of the left table. So Pluto, who never listened, returns as a tenth row with his listen columns NULL-filled.
RIGHT JOIN
RIGHT keeps every row of the right table, the mirror of LEFT. Keep all the Songs: not every song has a listen, so the unmatched rows show up on the right.
-- Keep every song, even the ones nobody ever played
SELECT s.song_id, s.title, l.rating
FROM Listens l
RIGHT JOIN Songs s
ON l.song_id = s.song_id
ORDER BY s.song_id;
Figure 2. Not every song has a listen: five were never played. INNER JOIN drops them; RIGHT JOIN keeps every song, NULL-filling the listen side for the five with no plays. Flip which table you keep, and the unmatched rows you preserve flip with it.
FULL OUTER JOIN
-- Keep every user AND every listen, padding with NULLs on either side
SELECT u.user_id, u.name, l.listen_id, l.rating
FROM Users u
FULL OUTER JOIN Listens l
ON u.user_id = l.user_id;
FULL OUTER JOIN is the union of LEFT + RIGHT. Every row from both tables stays, NULL-padded where they don't match: it keeps the unmatched rows from either side at once. SQLite doesn't support it; combine a LEFT JOIN with UNION and a RIGHT JOIN instead.
Foreign Keys
A foreign key forces every row on one side to have a match. Listens.user_id references Users, so every listen has a user. That is why Users RIGHT JOIN Listens came out identical to the inner join: there were no orphan listens to keep. An outer join only adds rows when the side it keeps has unmatched ones. Songs have no such rule, so five go unplayed, and a RIGHT JOIN on Songs surfaces them. On the foreign-key side, an outer join adds nothing; on the other side, it finds the orphans.
JOIN ON: Beyond Simple Equality
The ON clause accepts any boolean condition, not only equality. Combine predicates with AND/OR; it is evaluated for each candidate row pair.
Key Rules
-
Join Condition: The ON clause defines the relationship (e.g.,
Users.user_id = Listens.user_id). -
Join Order: Multiple JOINs are processed left-to-right, building intermediate results.
-
NULL Handling: Outer joins fill unmatched rows with NULLs.
-
Cartesian Product: A missing ON clause results in every possible combination, which is usually a mistake.
-
Column Ambiguity: Table aliases clarify which table a column belongs to.
-
Result Ordering: Without ORDER BY, rows return in an undefined order. Use ORDER BY for consistent results. (As we'll see later in Section 3, ordering incurs a performance cost due to sorting.)
Common Patterns
-
One-to-Many: One user can have many listens (Users → Listens).
-
Many-to-Many: Users listen to songs via a junction table (Users → Listens → Songs).
-
Optional Relationships: LEFT JOIN keeps all users, even those without listens.
-
Chaining: Connect 3+ tables by joining results with the next table.
Common Mistakes
Cartesian product warning: a missing ON clause multiplies rows.
4 users × 9 listens = 36 rows (see example below)
Forgetting JOIN conditions
Problem: Missing ON clause creates a cartesian product.
Wrong JOIN type for requirement
Problem: INNER JOIN excludes non-matching rows.
Ambiguous column names
Problem: Both tables have a user_id column.