open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 02 · 01

A join is a filtered product

Every join starts as the Cartesian product of two tables — every row of A paired with every row of B — and the ON predicate is just a filter on that product. Internalize this and join behavior stops being magic.

SQL Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

A reporting query that ran fine in staging returned 4.2 billion rows in production and pinned a CPU for nine minutes before someone killed it. The cause was one missing word in the ON clause. The author thought of a join as “matching rows up”. The engine thinks of it as something much blunter: pair everything with everything, then throw away what doesn’t match. Once you see joins the engine’s way, that nine-minute incident becomes obvious in advance. Ten minutes from now, you’ll know exactly what the engine does before it does it — and how to spot an explosion before the query runs.

The Cartesian product is the foundation

Forget “matching” for a moment. The most basic combination of two tables is the Cartesian product (the CROSS JOIN): take every row of users and pair it with every single row of orders. If users has 3 rows and orders has 4, the product has exactly 3 × 4 = 12 rows. No condition, no matching — just every possible pairing.

-- The raw product: every user paired with every order
SELECT u.id AS user_id, o.id AS order_id
FROM users u
CROSS JOIN orders o;
-- 3 users × 4 orders = 12 rows, regardless of who placed what

The row count of a product is |A| × |B| — it multiplies. That single fact is the whole reason a join can explode. Two tables of 100k rows have a product of 10 billion rows. The engine will never materialize all of those if it can help it, but the logical meaning of your query is still “start from that product.” When you see an unexpectedly large result set, the first question to ask is: where did my row count multiply?

An INNER JOIN is that product, filtered

An INNER JOIN ... ON <predicate> is definitionally the Cartesian product with the predicate applied as a filter. These two queries are logically identical:

-- Written as a join (what you'll always write)
SELECT u.id, o.id, o.total
FROM users u
JOIN orders o ON o.user_id = u.id;

-- Written as the product + filter (what it MEANS)
SELECT u.id, o.id, o.total
FROM users u
CROSS JOIN orders o
WHERE o.user_id = u.id;

Both ask: of all 12 possible pairings, keep the ones where o.user_id = u.id. The ON clause is not special syntax — it is a filter on the product. The planner never literally builds the 12-row grid (it uses hash or merge or nested-loop strategies to reach the same answer cheaply — that’s the databases track’s join-algorithms lesson), but the result is guaranteed to equal “product, then filter.”

This reframing pays off immediately. The output row count of an inner join is not |A| and not |B|. It is “how many pairs survive the filter,” which depends entirely on the relationship. If every order has exactly one matching user, the result has |orders| rows. If the join key is non-unique on both sides, you can get more rows than either table — a fan-out, the subject of this unit’s last lesson.

Why CROSS JOIN behavior is a feature, not just a footgun

The incident in the hook was an accidental product: someone wrote a join, forgot the ON predicate (or wrote ON true), and got |A| × |B|. SQL doesn’t stop you, because a product is sometimes exactly what you want — a calendar grid, an “every product × every size” combination matrix, filling gaps. The fourth lesson covers deliberate cross joins. The discipline: an ON clause that doesn’t actually constrain the relationship is silently a product, and a product on two large tables is an outage.

Why this works

Why does thinking “product then filter” matter when the planner never builds the product? Because it makes cardinality predictable, and cardinality is what governs cost. When you can answer “how many rows does this join emit?” before running it, you can spot fan-outs, accidental cross joins, and missing predicates by inspection. The planner’s whole job is to avoid materializing the product while returning the product-then-filter answer — but the answer is defined by the product, so your mental model has to start there. The databases execution-plans chapter shows how nested-loop, hash, and merge joins each reach that answer with different cost profiles.

Quiz

users has 5 rows, orders has 8 rows. You run SELECT * FROM users CROSS JOIN orders. How many rows come back?

Quiz

Which statement is logically equivalent to: FROM users u JOIN orders o ON o.user_id = u.id ?

Complete the analogy

Fill in the blank: an INNER JOIN is the Cartesian _______ of two tables with the ON predicate applied as a filter — every left row paired with every right row, then the non-matching pairs discarded.

Recall before you leave
  1. 01
    Define a join in terms of the Cartesian product and a predicate.
  2. 02
    users has 3 rows, orders has 4. What is the row count of a CROSS JOIN, and what does the inner join on o.user_id = u.id return instead?
  3. 03
    Why is 'product then filter' the right mental model even though Postgres never materializes the full product?
Recap

The foundation of every join is the Cartesian product: pair every row of A with every row of B, giving |A| × |B| rows — a count that multiplies, which is why joins can explode. A CROSS JOIN is that raw product; an INNER JOIN ... ON p is exactly the product with p applied as a filter, identical to CROSS JOIN ... WHERE p. Because of this, a join’s output size is “how many pairs survive the predicate,” never simply the size of either table — so a missing or too-loose ON clause silently degrades into a full product and an outage. The planner never builds the literal grid; it uses hash, merge, or nested-loop strategies (the databases join-algorithms lesson) to reach the same product-then-filter answer cheaply. Hold this model and the rest of the unit — outer joins, semi/anti joins, lateral, and fan-out traps — are all just variations on “which pairs do we keep, and what do we do with the rows that have no partner.” Now when you see a query return far more rows than expected, your first instinct will be to check the ON clause — because a missing or loose predicate is silently a full product, and products multiply.

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.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.