JOINs: Combining Tables
Concept. A JOIN pairs rows from two tables when a predicate in ON evaluates to true. The join type (INNER, LEFT, RIGHT, FULL) determines which unmatched rows remain in the output.
Intuition. An INNER JOIN of Users and Listens on user_id emits one output row per matching (user, listen) pair, so Mickey's 3 listens produce 3 output rows. A LEFT JOIN emits every Users row and uses NULLs for the Listens columns when no listen matches.
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.
INNER JOIN
INNER JOIN returns one row for each pair that satisfies ON. A user with several listens appears once per listen; a user with none does not appear.
Both tables have a user_id column. Qualify it as u.user_id or l.user_id to identify the intended table.
Figure: Users joined to Listens on user_idA Users table and a Listens table are matched on user_id. Mickey, Minnie and Daffy each pair with three listens, giving nine inner-join rows. Pluto has no listens, so the inner join drops him.
| user_id | name |
|---|---|
| 1 | Mickey |
| 2 | Minnie |
| 3 | Daffy |
| 4 | Pluto |
| user_id | song_id | rating |
|---|---|---|
| 1 | 1 | 4.5 |
| 1 | 2 | 4.2 |
| 1 | 6 | 3.9 |
| 2 | 2 | 4.7 |
| 2 | 7 | 4.6 |
| 2 | 8 | 3.9 |
| 3 | 1 | 2.9 |
| 3 | 2 | 4.9 |
| 3 | 6 | NULL |
| user_id | name | song_id | rating |
|---|---|---|---|
| 1 | Mickey | 1 | 4.5 |
| 1 | Mickey | 2 | 4.2 |
| 1 | Mickey | 6 | 3.9 |
| 2 | Minnie | 2 | 4.7 |
| 2 | Minnie | 7 | 4.6 |
| 2 | Minnie | 8 | 3.9 |
| 3 | Daffy | 1 | 2.9 |
| 3 | Daffy | 2 | 4.9 |
| 3 | Daffy | 6 | NULL |
-- Keep matched pairs; users without listens drop.
SELECT u.user_id, u.name, l.song_id, l.rating
FROM Users u
INNER JOIN Listens l ON u.user_id = l.user_id
ORDER BY u.user_id
Figure 1. Mickey, Minnie, and Daffy each match three listens: nine output rows. Pluto has no match.
A double loop is one way to trace the matching pairs; it does not specify the database’s execution plan.
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 | Beatles | 4.6 |
Listens connects users and songs in a many-to-many relationship: a user can play many songs, and a song can have many listeners.
Logically, the joins form rows before WHERE filters them. The optimizer may reorder equivalent inner joins and push the filter earlier.
Outer Joins: LEFT and RIGHT
LEFT JOIN
A LEFT JOIN returns the matched pairs and keeps unmatched rows from the table before JOIN. Here, that table is Users.
Figure: Users joined to Listens on user_id; LEFT JOIN keeps the unmatched PlutoA Users table and a Listens table are matched on user_id. Mickey, Minnie and Daffy each pair with three listens, giving nine inner-join rows. Pluto has no listens and no match: INNER JOIN drops him, while LEFT JOIN keeps him as a tenth row with NULL song_id and rating.
| user_id | name |
|---|---|
| 1 | Mickey |
| 2 | Minnie |
| 3 | Daffy |
| 4 | Pluto |
| user_id | song_id | rating |
|---|---|---|
| 1 | 1 | 4.5 |
| 1 | 2 | 4.2 |
| 1 | 6 | 3.9 |
| 2 | 2 | 4.7 |
| 2 | 7 | 4.6 |
| 2 | 8 | 3.9 |
| 3 | 1 | 2.9 |
| 3 | 2 | 4.9 |
| 3 | 6 | NULL |
| user_id | name | song_id | rating |
|---|---|---|---|
| 1 | Mickey | 1 | 4.5 |
| 1 | Mickey | 2 | 4.2 |
| 1 | Mickey | 6 | 3.9 |
| 2 | Minnie | 2 | 4.7 |
| 2 | Minnie | 7 | 4.6 |
| 2 | Minnie | 8 | 3.9 |
| 3 | Daffy | 1 | 2.9 |
| 3 | Daffy | 2 | 4.9 |
| 3 | Daffy | 6 | NULL |
| 4 | Pluto | NULL | NULL |
-- Keep every user; missing listen columns are NULL.
SELECT u.user_id, u.name, l.song_id, l.rating
FROM Users u
LEFT JOIN Listens l ON u.user_id = l.user_id
ORDER BY u.user_id
Figure 2. The nine matching listens remain. Pluto adds one row with NULL listen columns, giving ten rows in total.
RIGHT JOIN
A RIGHT JOIN keeps unmatched rows from the table after JOIN. Here, that table is Songs: the result includes songs that nobody played. Swapping the tables and using LEFT JOIN gives the same matched and unmatched pairs.
Figure: Listens RIGHT JOIN Songs keeps the five unplayed songsListens on the left and Songs on the right are matched on song_id. Five songs (Shape of You, Photograph, Shivers, Bad Blood, DJ Mix) were never played. INNER JOIN returns the nine played-song rows; RIGHT JOIN keeps all ten songs, adding the five unplayed ones NULL-filled, for fourteen rows.
| song_id | rating |
|---|---|
| 1 | 4.5 |
| 1 | 2.9 |
| 2 | 4.2 |
| 2 | 4.7 |
| 2 | 4.9 |
| 6 | 3.9 |
| 6 | NULL |
| 7 | 4.6 |
| 8 | 3.9 |
| song_id | title |
|---|---|
| 1 | Evermore |
| 2 | Willow |
| 3 | Shape of You |
| 4 | Photograph |
| 5 | Shivers |
| 6 | Yesterday |
| 7 | Yellow Submarine |
| 8 | Hey Jude |
| 9 | Bad Blood |
| 10 | DJ Mix |
| song_id | title | rating |
|---|---|---|
| 1 | Evermore | 4.5 |
| 1 | Evermore | 2.9 |
| 2 | Willow | 4.2 |
| 2 | Willow | 4.7 |
| 2 | Willow | 4.9 |
| 6 | Yesterday | 3.9 |
| 6 | Yesterday | NULL |
| 7 | Yellow Submarine | 4.6 |
| 8 | Hey Jude | 3.9 |
| 3 | Shape of You | NULL |
| 4 | Photograph | NULL |
| 5 | Shivers | NULL |
| 9 | Bad Blood | NULL |
| 10 | DJ Mix | NULL |
-- Keep every song; missing listen columns are NULL.
SELECT s.song_id, s.title, l.rating
FROM Listens l
RIGHT JOIN Songs s ON l.song_id = s.song_id
Figure 3. Nine listens produce nine matched rows. Five unplayed songs each add one row with NULL listen columns, giving fourteen rows.
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 keeps every matched pair and every unmatched row from both inputs. On engines without FULL OUTER JOIN, combine a LEFT JOIN with UNION ALL of the unmatched rows from the opposite side.
Foreign Keys
Every listen must match a user because Listens.user_id is NOT NULL and references Users.user_id. Neither constraint requires a user to have listens.
Figure: Every listen has a user. A user can have no listens.Listens.user_id is a non-NULL foreign key to Users.user_id, so every listen matches a user. The reverse is optional: Pluto is a user with no listens.
Figure 4. The foreign key guarantees a user for every listen. It does not require every user to have a listen: Pluto has none.
For Users u JOIN Listens l ON u.user_id = l.user_id:
-
INNER JOIN returns the nine listens with their users.
-
LEFT JOIN also keeps Pluto, giving ten rows.
-
RIGHT JOIN adds nothing: every listen already matches a user, so it returns the same nine rows as INNER JOIN.
LEFT and RIGHT refer to table positions. With the tables reversed, Listens LEFT JOIN Users returns the nine matches, while Listens RIGHT JOIN Users also keeps Pluto. If a foreign key allows NULL, rows with NULL keys need not match; the guarantee above depends on NOT NULL too.
JOIN ON: Beyond Simple Equality
The ON condition decides which row pairs match. It can use comparisons such as < or >=, and combine conditions with AND or OR; it need not be an equality test.
Missing Join Conditions
Users CROSS JOIN Listens pairs every user with every listen: 4 × 9 = 36 rows. Joining on u.user_id = l.user_id keeps only the nine pairs where the listen belongs to that user. Use a Cartesian product only when you want every combination; simply omitting ON from a JOIN may instead be a syntax error.
Takeaway
A join returns matching row pairs. Choose the join type based on which unmatched rows must survive.