Self and cross joins
A self-join joins a table to itself to walk hierarchies or compare rows pairwise; dedup pairs with a.id < b.id. An explicit CROSS JOIN builds deliberate products — calendar grids, gap-fills, all-combinations — distinct from an accidental comma join.
A “find duplicate products” query joined the products table to itself, matched on equal name, and returned 1.4 million pairs from a table of 1,200 dupes. Every duplicate was reported twice (A–B and B–A) and every row also matched itself (A–A). The logic was right; the dedup was missing. A self-join is the most error-prone join precisely because both sides are the same table — and the fix is one comparison operator most people forget.
Self-join: a table joined to itself
A self-join is just a join where both sides are the same table, given two different aliases. There’s nothing special in the engine — it’s the same filtered-product machinery — but it unlocks two whole classes of query.
Walking a hierarchy (adjacency list). A common pattern stores a tree by having each row point to its parent: users could have a referred_by column, or an employees-style table has manager_id referencing the same table’s id. To pair each employee with their manager’s name, join the table to itself:
-- Each employee alongside their manager's name (one level of hierarchy)
SELECT e.id, e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;
-- LEFT JOIN so the CEO (manager_id IS NULL) still appears, manager = NULLThe LEFT JOIN matters: the top of the tree has no parent, so an inner self-join would silently drop the root. (One self-join climbs exactly one level; arbitrary-depth ancestry needs a recursive CTE — unit 05.)
Pairwise comparison. The other use is comparing rows of a table against other rows of the same table: find duplicate products, users in the same city, orders within an hour of each other. This is where the hook’s bug lives.
-- Pairs of products with the same name — deduped and self-excluded
SELECT a.id AS id_a, b.id AS id_b, a.name
FROM products a
JOIN products b ON a.name = b.name
WHERE a.id < b.id; -- the critical lineWithout WHERE a.id < b.id, you get three kinds of garbage: each row matches itself (a.id = b.id), and each genuine pair appears twice in both orders (A–B and B–A). The predicate a.id < b.id solves both at once — it excludes self-matches (an id is never less than itself) and keeps only one ordering of each pair. Use < for unordered pairs; use <> only if you genuinely want both directions.
Cross join: a deliberate product
The last lesson framed the Cartesian product as a footgun. But sometimes the product is exactly the result you want, and you write CROSS JOIN to say so on purpose. The canonical case is a dense grid — every combination of two dimensions, including the empty cells.
-- Every (day, product) cell for a 7-day report, even days with zero sales
SELECT d.day, p.id AS product_id, COALESCE(s.qty, 0) AS qty
FROM generate_series(DATE '2026-06-01', DATE '2026-06-07', INTERVAL '1 day') AS d(day)
CROSS JOIN products p
LEFT JOIN sales s ON s.product_id = p.id AND s.sold_on = d.day;Here generate_series produces the 7 days, CROSS JOIN products makes every day-product combination (the scaffold), and the LEFT JOIN to actual sales fills in quantities, defaulting to 0 for empty cells. Without the cross join, days with no sales would be missing rows entirely — a classic reporting gap. Other deliberate uses: an “all sizes × all colors” variant matrix, or pairing each row with a fixed set of buckets.
The distinction the lesson title makes: an explicit CROSS JOIN reads as intent — “yes, I want the product.” A product that sneaks in through a comma join (FROM a, b with no WHERE linking them) or a too-loose ON is an accident and the outage from lesson 1. Write CROSS JOIN when you mean it; never let a product happen silently.
▸Why this works
Why is the explicit CROSS JOIN keyword worth using over the old comma syntax? Readability and safety. FROM a, b, c WHERE … hides whether the product is intended; a reviewer can’t tell a deliberate grid from a forgotten join condition. a CROSS JOIN b states the product is on purpose, and reserving the comma/JOIN forms for related tables makes a missing predicate visually obvious. Many teams lint against comma joins entirely for exactly this reason — the syntax that makes accidental products easy is the syntax worth banning.
A self-join finds pairs of users in the same city. Why add WHERE a.id < b.id?
You build a daily report and want every (day, product) cell to appear, including days with zero sales. Which structure guarantees the empty cells exist?
Fill in the blank: an explicit CROSS JOIN says the Cartesian product is _______ — written on purpose to build a grid — whereas a product that sneaks in via a comma join with no linking condition is an accident.
- 01What two problems does WHERE a.id < b.id solve in a pairwise self-join, and what would happen without it?
- 02Why use a LEFT JOIN (not INNER) when self-joining employees to their managers, and what does one self-join NOT do?
- 03Give a legitimate use of an explicit CROSS JOIN and explain why it beats an inner join here.
A self-join joins a table to itself under two aliases, reusing the ordinary filtered-product machinery for two jobs. For hierarchies (adjacency lists like employees.manager_id → id), join the table to itself with a LEFT JOIN so the root — whose parent is NULL — survives; note a single self-join climbs only one level, and arbitrary depth needs a recursive CTE (unit 05). For pairwise comparison (duplicates, same-city users), the indispensable line is WHERE a.id < b.id: it removes self-matches and the mirror-image duplicate of every pair in one stroke. A CROSS JOIN is the deliberate Cartesian product, the legitimate twin of lesson 1’s accidental one: generate_series(...) CROSS JOIN products LEFT JOIN sales builds a dense report grid where even zero-sale days appear, an all-combinations matrix the data alone can’t produce. Write CROSS JOIN to signal intent, and keep comma joins out of your codebase so a forgotten predicate can never masquerade as a grid. Now when you write a pairwise self-join, you’ll reach for WHERE a.id < b.id before running it — because without it, every real pair silently appears twice and every row matches itself.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.