Self-Joins, CROSS JOIN & Many-to-Many

Intermediate · 26 min read · ▶ live playground · ✦ checkpoint

Some joins look strange only because the match-maker has a new job. A self-join matches a table to itself, a CROSS JOIN returns every possible pairing, and a junction table lets two many-sided tables meet without stuffing lists into one cell.

The hard part is not memorizing new keywords. It is reading the grain of the result: what one row now represents.

Self-joins: one table, two roles

A self-join uses the same table twice with different aliases. The café staff table has manager_id, which stores another staff member's id, so the table can play two roles: worker and manager.

SELECT s.name AS staff_member,
       s.role,
       m.name AS manager
FROM staff AS s
LEFT JOIN staff AS m
  ON s.manager_id = m.id
ORDER BY s.id;
 staff_member |  role   | manager
--------------+---------+---------
 Rosa         | owner   | NULL
 Ken          | manager | Rosa
 Ada          | barista | Ken
 Tomas        | barista | Ken
 Yuki         | baker   | Ken
 Priya        | barista | Ken
(6 rows)

staff AS s means "the staff member row." staff AS m means "the manager row." Same stored table, two table aliases, two meanings.

This is also the pattern for flights and connections. One alias can mean "first flight," another can mean "next flight," and the ON clause matches first arrival airport to next departure airport. The table is the same; the roles are different.

CROSS JOIN: every combination

A CROSS JOIN has no ON clause because it does not look for a relationship. It pairs every row on the left with every row on the right.

SELECT s.name,
       slot.slot
FROM staff AS s
CROSS JOIN shift_slots AS slot
WHERE s.role = 'barista'
ORDER BY s.id, slot.id;
 name  |  slot
-------+---------
 Ada   | morning
 Ada   | closing
 Tomas | morning
 Tomas | closing
 Priya | morning
 Priya | closing
(6 rows)

That is useful when every combination is the point: possible shift assignments, product-size menus, calendar slots, test cases. It is dangerous when accidental. If the question needed a keyed match and you wrote CROSS JOIN anyway, the row count can explode while the query still runs. Plain JOIN without ON is a syntax error in Postgres, which is kinder than a silent every-combination result.

Many-to-many uses a table in the middle

A many-to-many relationship means many rows on one side can match many rows on the other. The school version is students -> enrollments -> courses. In the café, customers can sign up for many workshops, and each workshop can have many customers:

SELECT c.name,
       w.title
FROM customers AS c
JOIN workshop_signups AS ws
  ON ws.customer_id = c.id
JOIN workshops AS w
  ON w.id = ws.workshop_id
ORDER BY c.id, w.id;
  name  |      title
--------+------------------
 Ada    | Latte Art
 Ada    | Home Espresso
 Grace  | Latte Art
 Linus  | Latte Art
 Linus  | Pour-over Basics
 Edsger | Home Espresso
(6 rows)

Read chained joins left to right. Start with customers. JOIN workshop_signups changes the grain to one row per customer-signup. JOIN workshops adds the workshop facts for each signup. If you chain four or five joins, keep asking the same question after each one: what does one row represent now?

Fan-out: when matches multiply rows

Fan-out is row multiplication caused by one row matching several rows. It is normal when the grain calls for it, and a bug when you forget it happened.

Watch what happens when order rows and workshop signup rows both hang off the same customer:

SELECT c.name,
       o.id AS order_id,
       o.amount,
       w.title
FROM customers AS c
JOIN orders AS o
  ON o.customer_id = c.id
JOIN workshop_signups AS ws
  ON ws.customer_id = c.id
JOIN workshops AS w
  ON w.id = ws.workshop_id
ORDER BY c.id, o.id, w.id;
  name  | order_id | amount |      title
--------+----------+--------+------------------
 Ada    |      101 | 220.00 | Latte Art
 Ada    |      101 | 220.00 | Home Espresso
 Ada    |      102 |  92.00 | Latte Art
 Ada    |      102 |  92.00 | Home Espresso
 Grace  |      103 |  45.00 | Latte Art
 Linus  |      104 | 220.00 | Latte Art
 Linus  |      104 | 220.00 | Pour-over Basics
 Linus  |      105 |   8.00 | Latte Art
 Linus  |      105 |   8.00 | Pour-over Basics
 Edsger |      106 |  45.00 | Home Espresso
 Edsger |      107 |  60.00 | Home Espresso
(11 rows)

This grid is correct if the question is "which order-workshop combinations exist for each customer?" It is wrong if you forgot that each order can repeat once for every workshop signup.

The bad version shows up as a plausible total:

SELECT c.name,
       SUM(o.amount) AS reported_total
FROM customers AS c
JOIN orders AS o
  ON o.customer_id = c.id
JOIN workshop_signups AS ws
  ON ws.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;
 
SELECT c.name,
       SUM(o.amount) AS order_total
FROM customers AS c
JOIN orders AS o
  ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;
  name  | reported_total
--------+----------------
 Ada    |         624.00
 Grace  |          45.00
 Linus  |         456.00
 Edsger |         105.00
(4 rows)
  name  | order_total
--------+-------------
 Ada    |      312.00
 Grace  |       45.00
 Linus  |      228.00
 Edsger |      105.00
(4 rows)

The fix is not magic. Ask the query's grain question before you aggregate. If the report is order totals, stay on the customer-order path. If the report is workshop attendance, stay on the customer-signup-workshop path. Joining both paths creates a wider question than your SUM sentence says.

sql — playgroundlive
⌘/Ctrl + Enter to run

You now have the full join vocabulary: matched pairs, kept unmatched rows, self-joins, every-combination joins, and many-to-many chains. Section 6 turns back inside a row and gives you the function toolkit for shaping text, dates, and numbers after the right rows are on the screen.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion