Filter concepts by levelShowing all levels.

PostgreSQL · Section 3

Joins

Level
intermediate
Read
32 min
Concepts
6

The four join types and what each one does with an unmatched row, CROSS JOIN and self joins as two different problems that share the word "join," why a predicate is both a correctness statement and a performance lever, and how to recognize — and fix — a row-count explosion before it reaches an aggregate.

This section
Joins in PostgreSQLAlex The Analyst

What is true here

  1. INNER JOIN drops unmatched rows; LEFT JOIN keeps every left row, padding the right side with NULL.
  2. CROSS JOIN pairs every row with every row on purpose — a comma-join with no WHERE predicate does it by accident.
  3. A join predicate is both a correctness statement and a performance lever the planner reasons about.
  4. The relationship shape (one-to-one, one-to-many, many-to-many) lives in the schema, not the query.
  5. Two independent one-to-many joins in the same query multiply row counts — aggregate each child before joining.

What you will be able to do

  • Predict exactly which rows a given join type keeps or drops
  • Tell an intentional CROSS JOIN from an accidental comma-join Cartesian product
  • Choose a join predicate that is both correct and index-friendly
  • Diagnose a surprising row count by isolating one join at a time
What decides how many rows a join produces

Predicate

is ON matching the real key?

Relationship shape

1:1, 1:many, or many:many

Join type

INNER drops, LEFT/FULL keep+pad

Resulting row count

verify with COUNT(*), one join at a time

  • Predicate — is ON matching the real key?
    • leads to Relationship shape
  • Relationship shape — 1:1, 1:many, or many:many
    • leads to Join type
  • Join type — INNER drops, LEFT/FULL keep+pad
    • leads to Resulting row count
  • Resulting row count — verify with COUNT(*), one join at a time

The four join types

What each join type does with a row that has no match on the other side.

INNER JOIN, LEFT JOIN, RIGHT JOIN and FULL OUTER JOIN

corebeginner

INNER JOIN keeps only rows that match on both sides. LEFT JOIN keeps every row from the left table, filling in NULLs when there is no match on the right. RIGHT JOIN is the mirror image. FULL OUTER JOIN keeps every row from both sides, matched or not.

Think of it as

Think of the join type as answering one question: "what happens to a row that has no match?" INNER JOIN drops it. LEFT JOIN keeps the left row and pads the right side with NULL. RIGHT JOIN keeps the right row and pads the left side with NULL. FULL OUTER JOIN keeps both sides' unmatched rows, padding whichever side is missing.

sql
SELECT ... FROM a INNER JOIN b ON a.key = b.key;
SELECT ... FROM a LEFT JOIN b ON a.key = b.key;
SELECT ... FROM a RIGHT JOIN b ON a.key = b.key;
SELECT ... FROM a FULL OUTER JOIN b ON a.key = b.key;

What we're doing: Compare INNER JOIN and LEFT JOIN against the same two tables to see exactly which rows each one drops.

join_types.sqlsql
CREATE TABLE customers (id SERIAL PRIMARY KEY, name TEXT);
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id), total NUMERIC);

INSERT INTO customers (name) VALUES ('Ada'), ('Grace'), ('Linus');
INSERT INTO orders (customer_id, total) VALUES (1, 50), (1, 20);
-- Linus (id 3) has placed no orders

SELECT c.name, o.total
FROM customers c INNER JOIN orders o ON o.customer_id = c.id;

SELECT c.name, o.total
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id;
1–2
orders.customer_id references customers — every order optionally points at a customer.
4–5
Ada has two orders, Grace has none inserted here either, Linus has none.
7–8
INNER JOIN: only Ada's two rows survive — Grace and Linus have no matching order row.
10–11
LEFT JOIN: Ada's two rows plus one row each for Grace and Linus, with total as NULL.
Output
name | total
-----+------
Ada  |    50
Ada  |    20
(2 rows)

name   | total
-------+------
Ada    |    50
Ada    |    20
Grace  |     
Linus  |     
(4 rows)

Why this works: PostgreSQL evaluates the join predicate for every combination of rows, then INNER JOIN filters to only the matches, while LEFT JOIN additionally injects one padded row for every left row that matched nothing. This is exactly why LEFT JOIN can return more rows than INNER JOIN on the same predicate but never fewer, and why aggregate counts like "orders per customer" are wrong under INNER JOIN for customers with zero orders — they disappear entirely instead of showing zero.

Using INNER JOIN to count zero-order customers

Wrong

sql
SELECT c.name, COUNT(o.id) AS order_count
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;
-- Grace and Linus never appear — they have no matching order row

Better

sql
SELECT c.name, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;
-- Grace and Linus appear with order_count = 0

What you see: A "count per customer" report silently omits every customer who has zero of the thing being counted, instead of showing them with a count of 0.

Why: INNER JOIN removes a customer row entirely the moment it has no matching order row, so there is nothing left for GROUP BY to aggregate into a zero — the customer just never enters the result set. LEFT JOIN keeps the customer row with orders.id as NULL, and COUNT(o.id) correctly counts zero NULLs, producing the 0 the report actually needs.

INNER vs LEFT — same tables, different treatment of a no-match

INNER JOIN

  • +customers × orders, matched only
  • +a customer with no orders vanishes from the result
  • +result size can only shrink or stay equal

LEFT JOIN

  • every customer row survives
  • a customer with no orders gets one row, order columns NULL
  • result size is at least the left table's row count
  • INNER JOIN
    • customers × orders, matched only
    • a customer with no orders vanishes from the result
    • result size can only shrink or stay equal
  • LEFT JOIN
    • every customer row survives
    • a customer with no orders gets one row, order columns NULL
    • result size is at least the left table's row count

What happens to an unmatched row, by join type

What happens to an unmatched row, by join type
Join typeUnmatched left rowUnmatched right row
INNER JOINdroppeddropped
LEFT JOINkept, right columns NULLdropped
RIGHT JOINdroppedkept, left columns NULL
FULL OUTER JOINkept, right columns NULLkept, left columns NULL

Together

sql
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;
-- every customer appears, even ones with zero orders (order_id is NULL)

Remember: INNER drops unmatched rows on both sides; LEFT keeps every left row; RIGHT keeps every right row; FULL OUTER keeps both — the join type is really just "what happens to a row with no match."

See also: cross and self joins · cardinality and relationships

CROSS JOIN and Self Joins

standardintermediate

CROSS JOIN pairs every row of one table with every row of another, with no matching condition — a deliberate Cartesian product. A self join joins a table to itself, using two aliases, typically to compare rows within the same table.

Think of it as

CROSS JOIN and self join solve different problems that happen to both use the word "join." CROSS JOIN answers "give me every combination" — sizes, dates, colors. A self join answers "how does this row relate to another row in the same table" — an employee and their manager, both stored in one employees table.

sql
SELECT ... FROM a CROSS JOIN b;

SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

What we're doing: Use a self join to pair each employee with their manager's name, both coming from the same employees table.

self_join.sqlsql
CREATE TABLE employees (
    id SERIAL PRIMARY KEY,
    name TEXT,
    manager_id INT REFERENCES employees(id)
);

INSERT INTO employees (name, manager_id) VALUES ('Ada', NULL);
INSERT INTO employees (name, manager_id) VALUES ('Grace', 1), ('Linus', 1);

SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY e.id;
4
manager_id is a foreign key into the same table — the source of the self-referencing relationship.
6
Ada has no manager (manager_id is NULL) — the company's top row.
9–11
e and m are two aliases for the same employees table, playing two different roles in one query.
Output
employee | manager
---------+--------
Ada      |        
Grace    | Ada
Linus    | Ada
(3 rows)

Why this works: PostgreSQL has no special "self join" syntax — it is an ordinary JOIN where both sides happen to reference the same table, made possible only because SQL requires every table in a query to have a name, and an alias gives the same physical table two independent names to join against. LEFT JOIN here (not INNER JOIN) matters specifically because Ada has no manager: an INNER JOIN would drop her row entirely, the same unmatched-row behavior the inner-vs-left concept covers, now applied within a single table instead of across two.

Forgetting a join predicate and getting an accidental CROSS JOIN

Wrong

sql
SELECT s.name, c.name FROM sizes s, colors c;
-- comma join with no WHERE predicate — a Cartesian product, probably unintended

Better

sql
SELECT s.name, c.name FROM sizes s CROSS JOIN colors c;
-- same result, but the CROSS JOIN keyword makes the Cartesian product explicit and searchable

What you see: A query returns far more rows than expected — often rows_a × rows_b instead of the intended matched pairs — with no error, because comma-separated tables with no WHERE predicate silently produce every combination.

Why: A comma-separated FROM list is old-style implicit join syntax where the join type depends entirely on whether a matching WHERE predicate is present — forgetting it does not raise an error, it just silently becomes a Cartesian product. Explicit CROSS JOIN makes an intentional Cartesian product visible to anyone reading the query, and its absence from a JOIN...ON chain reads as a clear signal that a predicate is missing rather than an ambiguous comma.

CROSS JOIN vs self join — what each actually answers

CROSS JOIN vs self join — what each actually answers
ShapeQuestion it answersTypical use
CROSS JOIN"every combination of A and B"generating a size × color product matrix
Self join"how does this row relate to another row in the same table?"employee → manager, both in employees

Together

sql
SELECT s.name AS size, c.name AS color
FROM sizes s CROSS JOIN colors c;
-- every size paired with every color

Remember: CROSS JOIN pairs every row with every row on purpose (no ON clause); a self join is an ordinary join where two aliases point at the same table to compare rows within it.

See also: inner left right full · cartesian products and duplication

Advertisement

Writing correct, fast joins

The predicate decides both correctness and whether the planner can use an index.

How Join Predicates Affect Correctness and Performance

standardintermediate

The ON clause decides which rows count as a match — get it wrong and the join is silently incorrect, not just slow. A predicate on an indexed, correctly-typed column also lets the planner use an index; a predicate wrapped in a function or comparing mismatched types often cannot.

Think of it as

A join predicate does two jobs at once: it defines correctness (which pairs of rows count as related) and it is the thing the query planner reasons about for performance (can this be answered with an index lookup, or does every row on one side need to be compared to every row on the other?). A predicate can be simultaneously "correct" and "slow" — the planner cannot use an index it cannot reason about, even if the logic is right.

sql
-- correct and index-friendly
SELECT ... FROM a JOIN b ON a.b_id = b.id;

-- correctness bug: matches more than intended
SELECT ... FROM a JOIN b ON a.status = b.status;

What we're doing: Compare a correct key-based join predicate against a plausible-looking but wrong predicate that joins on a non-unique column.

predicate_correctness.sqlsql
CREATE TABLE customers (id SERIAL PRIMARY KEY, region TEXT);
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT, region TEXT);

INSERT INTO customers (region) VALUES ('west'), ('west');
INSERT INTO orders (customer_id, region) VALUES (1, 'west');

-- correct: joins on the actual foreign key
SELECT c.id, o.id FROM customers c JOIN orders o ON o.customer_id = c.id;

-- wrong: joins on region, which is not unique -- multiplies matches
SELECT c.id, o.id FROM customers c JOIN orders o ON o.region = c.region;
4–5
Two customers share region 'west'; one order also has region 'west'.
8
The key-based join returns exactly one row: order 1 belongs to customer 1.
10–11
The region-based join matches the one order against BOTH customers with region 'west', producing two rows for a single real order.
Output
id | id
---+---
 1 |  1
(1 row)

id | id
---+---
 1 |  1
 2 |  1
(2 rows)

Why this works: The planner has no way to know that region "should" behave like a unique key — it faithfully joins on exactly the predicate given, and region genuinely matches two customer rows, so the second query is not a bug in PostgreSQL, it is a bug in the predicate. A foreign key column is the right join target specifically because a foreign key constraint guarantees it references exactly one row on the other side, which is what makes the join predicate's cardinality predictable.

Joining on a business-meaningful but non-unique column instead of the key

Wrong

sql
SELECT c.id, o.id FROM customers c JOIN orders o ON o.region = c.region;
-- region is shared by many customers -- silently duplicates matches

Better

sql
SELECT c.id, o.id FROM customers c JOIN orders o ON o.customer_id = c.id;
-- customer_id is a foreign key to customers.id -- guaranteed at most one match

What you see: A report shows the same order total multiple times, or a downstream SUM()/COUNT() comes back inflated, with no query error to point at the cause.

Why: A join predicate on a non-unique column produces one output row per matching pair on both sides, so if two rows on the left match one row on the right, that right row is duplicated in the output — this is exactly how joins are defined to work, which is why the fix is always to join on a column the schema guarantees is unique on at least one side, typically a primary or foreign key.

Predicate shape vs what the planner can do with it

Predicate shape vs what the planner can do with it
Predicate shapeCan use a plain B-tree index?
a.customer_id = b.id (same type)yes
a.customer_id = b.id (int vs text, implicit cast)often no
lower(a.email) = b.emailno — needs an expression index on lower(a.email)
a.customer_id = b.id AND a.activeyes, on the equality part

Together

sql
EXPLAIN SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;
-- Hash Join or Nested Loop with an Index Scan, depending on table size

Remember: The ON clause is both a correctness statement (which rows count as related) and a performance lever (what the planner can index) — join on a column the schema guarantees is unique on at least one side, usually a foreign key.

See also: inner left right full · cardinality changes and verification

One-to-One, One-to-Many and Many-to-Many Joins

standardintermediate

One-to-one means each row on one side matches at most one row on the other. One-to-many means one row on one side can match several rows on the other. Many-to-many needs a junction table in between, because neither side alone can hold the relationship.

Think of it as

The relationship shape is a property of the schema (which foreign keys and unique constraints exist), and the join just exposes it. A join does not create a one-to-many relationship — it reveals one that the foreign key already implies, and the row count a join produces is a direct consequence of that shape, not an accident of the query.

sql
-- one-to-many
SELECT p.name, o.id FROM products p JOIN order_items o ON o.product_id = p.id;

-- many-to-many via a junction table
SELECT s.name, c.title
FROM students s
JOIN enrollments e ON e.student_id = s.id
JOIN courses c ON c.id = e.course_id;

What we're doing: Model a many-to-many relationship (students and courses) through a junction table and observe the row multiplication a plain join produces.

many_to_many.sqlsql
CREATE TABLE students (id SERIAL PRIMARY KEY, name TEXT);
CREATE TABLE courses (id SERIAL PRIMARY KEY, title TEXT);
CREATE TABLE enrollments (
    student_id INT REFERENCES students(id),
    course_id INT REFERENCES courses(id),
    PRIMARY KEY (student_id, course_id)
);

INSERT INTO students (name) VALUES ('Ada');
INSERT INTO courses (title) VALUES ('Databases'), ('Algorithms');
INSERT INTO enrollments VALUES (1, 1), (1, 2);

SELECT s.name, c.title
FROM students s
JOIN enrollments e ON e.student_id = s.id
JOIN courses c ON c.id = e.course_id;
3–7
enrollments is the junction table — its composite primary key prevents duplicate enrollment rows.
9–10
Ada enrolls in both courses — the relationship lives in enrollments, not in students or courses.
12–15
Two joins route through the junction table; Ada's single student row now appears twice, once per course.
Output
name | title
-----+------------
Ada  | Databases
Ada  | Algorithms
(2 rows)

Why this works: A student row and a course row cannot reference each other directly, because either one could relate to many of the other — the junction table exists specifically to hold pairs, one row per (student, course) combination, with a composite primary key preventing the same pair from being recorded twice. Ada's row appearing twice in the output is not duplication in the buggy sense — it is the correct representation of "Ada is enrolled in two courses" once the relationship is flattened into a row-per-pair result set.

Adding a course_id column directly on students to model many-to-many

Wrong

sql
ALTER TABLE students ADD COLUMN course_id INT REFERENCES courses(id);
-- a student can now reference only ONE course -- the relationship is really many-to-many

Better

sql
CREATE TABLE enrollments (
    student_id INT REFERENCES students(id),
    course_id INT REFERENCES courses(id),
    PRIMARY KEY (student_id, course_id)
);

What you see: Enrolling a student in a second course requires overwriting course_id, silently dropping their first enrollment, because the column can only ever hold one value.

Why: A single foreign-key column on students can represent at most a one-to-many relationship (one course, many students) — it structurally cannot represent a student taking multiple courses. A junction table is the only shape that lets both sides vary independently, because each row in it is an independent fact ("this student, this course") rather than a property forced onto one side.

Relationship shape → schema signal → join row-count effect

Relationship shape → schema signal → join row-count effect
ShapeSchema signalEffect of joining
One-to-oneFK column also UNIQUErow count unchanged
One-to-manyplain FK, no uniqueness"one" side rows repeat per match
Many-to-manyjunction table with two FKsboth sides repeat, routed through the junction

Together

sql
-- one-to-many: many order_items per order
SELECT o.id, i.sku FROM orders o JOIN order_items i ON i.order_id = o.id;

Remember: The relationship shape lives in the schema, not the query: a UNIQUE foreign key is one-to-one, a plain foreign key is one-to-many, and two foreign keys on a junction table is many-to-many.

See also: cartesian products and duplication · junction tables for many to many

Advertisement

Catching row explosions

Recognizing and fixing the classic bug where a join silently multiplies rows.

Recognizing Accidental Cartesian Products and Duplicate-Row Explosions

coreintermediate

A row-count explosion happens when a query joins through a one-to-many relationship without accounting for it — a single "one" row appears once per match on the "many" side, inflating any SUM or COUNT computed alongside it.

Think of it as

The classic case is not a missing ON clause — it is joining a table to two different one-to-many children in the same query. Each child multiplies the row count independently, so joining an order to both its line items and its shipment events does not add rows, it multiplies them: 3 line items × 2 shipment events becomes 6 rows for one order.

sql
-- wrong: SUM inflated by the shipments join
SELECT o.id, SUM(i.price)
FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN shipments s ON s.order_id = o.id
GROUP BY o.id;

-- right: aggregate order_items first, then join once
SELECT o.id, totals.item_total
FROM orders o
JOIN (SELECT order_id, SUM(price) AS item_total FROM order_items GROUP BY order_id) totals
    ON totals.order_id = o.id;

What we're doing: Reproduce a duplicate-row explosion from two chained one-to-many joins, then fix it by pre-aggregating before joining.

row_explosion.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY);
CREATE TABLE order_items (order_id INT REFERENCES orders(id), price NUMERIC);
CREATE TABLE shipments (order_id INT REFERENCES orders(id), event TEXT);

INSERT INTO orders DEFAULT VALUES;
INSERT INTO order_items (order_id, price) VALUES (1, 10), (1, 20), (1, 5);
INSERT INTO shipments (order_id, event) VALUES (1, 'packed'), (1, 'shipped');

-- wrong: SUM(price) is computed once per shipment event
SELECT o.id, SUM(i.price) AS total
FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN shipments s ON s.order_id = o.id
GROUP BY o.id;

-- right: aggregate order_items first
SELECT o.id, totals.total
FROM orders o
JOIN (SELECT order_id, SUM(price) AS total FROM order_items GROUP BY order_id) totals
    ON totals.order_id = o.id;
5–6
One order has 3 line items totaling 35, and 2 shipment events — two independent one-to-many relationships.
9–13
Joining both children produces 3 × 2 = 6 rows for this one order before GROUP BY runs, so SUM sees each price twice.
16–19
The subquery aggregates order_items down to one row per order first, so the join to orders never multiplies anything.
Output
id | total
---+------
 1 |    70
(1 row)

id | total
---+------
 1 |    35
(1 row)

Why this works: GROUP BY runs after the FROM clause has already produced its full row set, so it has no way to know that the 6 intermediate rows represent only 3 distinct line items — it just sums whatever SUM(price) sees across every one of those 6 rows, and each of the 3 real prices appears twice because of the 2 shipment events. Pre-aggregating order_items in a subquery collapses the one-to-many relationship down to exactly one row per order before it ever reaches the join to shipments, so nothing downstream can multiply it.

Joining two independent one-to-many children in the same query before aggregating

Wrong

sql
SELECT o.id, SUM(i.price) FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN shipments s ON s.order_id = o.id
GROUP BY o.id;

Better

sql
SELECT o.id, totals.total FROM orders o
JOIN (SELECT order_id, SUM(price) AS total FROM order_items GROUP BY order_id) totals
    ON totals.order_id = o.id;

What you see: A total silently doubles, triples, or otherwise multiplies by exactly the row count of an unrelated joined table, with no error — the query runs and returns a plausible-looking wrong number.

Why: Each additional one-to-many join multiplies the row count by that table's match count for the given parent, and SUM/COUNT/AVG all operate on whatever rows physically exist in the result set at the point GROUP BY runs — they cannot detect that some of those rows represent the same underlying fact repeated. The fix is structural, not a DISTINCT patch: aggregate each one-to-many child down to one row per parent before joining it to anything else that could also multiply.

One order, two one-to-many joins, multiplied rows
JOINorder_itemsJOINshipments

1 order

orders.id = 1

× 3 order_items

one row per item

× 2 shipment events

independent one-to-many

6 result rows

3 × 2, not 3 + 2

  • 1 order — orders.id = 1
    • leads to × 3 order_items (JOIN order_items)
  • × 3 order_items — one row per item
    • leads to × 2 shipment events (JOIN shipments)
  • × 2 shipment events — independent one-to-many
    • leads to 6 result rows
  • 6 result rows — 3 × 2, not 3 + 2

How a row count grows through chained one-to-many joins

How a row count grows through chained one-to-many joins
Joined tablesRows per orderWhy
orders alone1baseline
orders + order_items (3 items)3one row per item
orders + order_items (3) + shipments (2)6every item × every shipment event

Together

sql
SELECT o.id, SUM(i.price) FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN shipments s ON s.order_id = o.id
GROUP BY o.id;
-- price is summed once per shipment event -- inflated

Remember: Two independent one-to-many joins in the same query multiply row counts, not add them — aggregate each child down to one row per parent first, then join the pre-aggregated result.

See also: cardinality and relationships · cardinality changes and verification

Determining Why a JOIN Changes Row Cardinality

standardintermediate

Before trusting a join's row count, ask three questions in order: is the join predicate correct, is the relationship one-to-one/one-to-many/many-to-many, and does the join type (INNER vs LEFT) add or drop unmatched rows. Each answer independently changes the row count.

Think of it as

Row-count changes from a join are never mysterious — they come from exactly three independently checkable sources: the predicate (is it matching the right column?), the relationship shape (one row can legitimately match many), and the join type (unmatched rows kept or dropped). Debugging a surprising row count means checking these three in order rather than guessing.

sql
EXPLAIN ANALYZE
SELECT o.id, i.sku
FROM orders o
JOIN order_items i ON i.order_id = o.id;
-- "actual rows" at each join node shows exactly how many rows it produced

What we're doing: Diagnose a surprising row count by adding one join at a time and comparing COUNT(*) after each step.

diagnose_cardinality.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY);
CREATE TABLE order_items (order_id INT REFERENCES orders(id), sku TEXT);

INSERT INTO orders DEFAULT VALUES;
INSERT INTO orders DEFAULT VALUES;
INSERT INTO order_items (order_id, sku) VALUES (1, 'A'), (1, 'B'), (2, 'C');

SELECT count(*) FROM orders;
-- 2

SELECT count(*) FROM orders o JOIN order_items i ON i.order_id = o.id;
-- 3 -- order 1 has 2 items, order 2 has 1, no orders were dropped
4–5
Two orders exist as the baseline — this is the number to compare every subsequent join against.
7
Baseline count: 2 rows, one per order.
10–12
After joining order_items, count is 3, not 2 — expected, since order 1 legitimately has 2 items (one-to-many) and no order was dropped (both orders had at least one item, so INNER JOIN lost nothing here).
Output
count
-----
    2
(1 row)

count
-----
    3
(1 row)

Why this works: Checking count(*) before adding a join, then again immediately after, isolates the effect of exactly one join at a time — if the count jumps by more than expected, the newly added join is the cause, not some earlier part of the query. Here 3 is correct and explainable (one order contributes 2 rows because it has 2 items), which is the difference between a surprising-but-correct cardinality change and an actual bug: the incremental check lets you tell them apart instead of guessing from the final number alone.

Debugging a wrong row count by staring at the final, fully-joined query

Wrong

sql
SELECT o.id, SUM(i.price) FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN shipments s ON s.order_id = o.id
JOIN refunds r ON r.order_id = o.id
GROUP BY o.id;
-- total is wrong; unclear which join caused it

Better

sql
SELECT count(*) FROM orders o JOIN order_items i ON i.order_id = o.id;
SELECT count(*) FROM orders o JOIN order_items i ON i.order_id=o.id JOIN shipments s ON s.order_id=o.id;
-- add one join at a time, compare counts, find exactly where it jumps

What you see: A multi-join query produces a wrong aggregate, and it is unclear which of several joins is responsible, so the debugging session becomes guesswork instead of a search.

Why: Each join is an independent operation the planner applies in sequence conceptually, even though it may reorder them physically — so isolating one join at a time by re-running COUNT(*) after each addition turns "which of these four joins broke it" into a linear search with a clear stopping point: the first join where the count jumps unexpectedly is the one to fix.

Three independent questions to ask about a surprising row count

Three independent questions to ask about a surprising row count
QuestionWhat to check
Is the predicate right?is it comparing the actual key columns, correctly typed?
What is the relationship shape?one-to-one, one-to-many, or many-to-many — via schema constraints
What join type is used?INNER drops unmatched rows, LEFT/FULL keep and pad them

Together

sql
SELECT count(*) FROM orders;                                     -- baseline
SELECT count(*) FROM orders o JOIN order_items i ON i.order_id=o.id; -- after join 1

Remember: A join's row count changes for exactly one of three reasons: the predicate, the relationship shape, or the join type — isolate which one by checking COUNT(*) after each join is added, one at a time.

See also: cartesian products and duplication · join predicates and performance

Advertisement