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.

Users joined to Listens on user_id: Mickey, Minnie and Daffy each match three listens, giving nine inner-join rows; Pluto has no listens and is dropped.

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_idnameemail
1Mickeymickey@example.com
2Minnieminnie@example.com
3Daffydaffy@example.com
4Plutopluto@example.com

Listens Table (9 rows)

listen_iduser_idsong_idratinglisten_time
1114.52024-08-30 14:35:00
2124.2NULL
3163.92024-08-29 10:15:00
4224.7NULL
5274.62024-08-28 09:20:00
6283.92024-08-27 16:45:00
7312.9NULL
8324.92024-08-26 12:30:00
936NULLNULL

-- 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_idnamelisten_idsong_idrating
1Mickey114.5
1Mickey224.2
1Mickey363.9
2Minnie424.7
2Minnie574.6
2Minnie683.9
3Daffy712.9
3Daffy824.9
3Daffy96NULL
  • 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;
nametitleartistrating
DaffyWillowTaylor Swift4.9
MickeyEvermoreTaylor Swift4.5
MickeyWillowTaylor Swift4.2
MinnieWillowTaylor Swift4.7
MinnieYellow SubThe Beatles4.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.

Users LEFT JOIN Listens: Mickey, Minnie and Daffy match three listens each (nine rows); Pluto has no match, but LEFT JOIN keeps him as a tenth row, 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;

Ten songs joined to nine listens 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.

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 (Listens.user_id references Users) means every listen has a user, so Users RIGHT JOIN Listens equals the inner join with no orphans; songs have no such rule, so five unplayed songs make RIGHT JOIN differ, at fourteen rows.

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

  1. Join Condition: The ON clause defines the relationship (e.g., Users.user_id = Listens.user_id).

  2. Join Order: Multiple JOINs are processed left-to-right, building intermediate results.

  3. NULL Handling: Outer joins fill unmatched rows with NULLs.

  4. Cartesian Product: A missing ON clause results in every possible combination, which is usually a mistake.

  5. Column Ambiguity: Table aliases clarify which table a column belongs to.

  6. 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 the ON clause pairs every user with every listen: a 4 by 9 grid of 36 cells, only 9 of which (matching user_id) are real matches; the other 27 are meaningless.

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.