PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 6: Joins

Joins: inner, outer, semi, anti and self

Level 2: Querying25 min

A join combines rows from two relations on a condition. The join type decides what happens to rows without a partner, and that choice is where most wrong reports come from.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Choose between INNER, LEFT, RIGHT, FULL and CROSS joins
  • Avoid the classic LEFT JOIN that silently becomes an inner join
  • Write semi-joins with EXISTS, anti-joins with NOT EXISTS, and self-joins

conceptThe concept

INNER JOIN keeps only pairs that match. LEFT JOIN keeps every row on the left and fills the right side with NULLs when nothing matches; RIGHT is the mirror image and FULL keeps unmatched rows from both sides. CROSS JOIN is every combination.

The condition in ON decides which rows match; the condition in WHERE decides which rows survive. With an outer join they are not interchangeable: a WHERE filter on a right-side column throws away the NULL-filled rows and turns the LEFT JOIN back into an inner join.

A semi-join asks "does a partner exist?" without multiplying rows (EXISTS, IN). An anti-join asks "is there no partner?" (NOT EXISTS, or LEFT JOIN ... WHERE right.key IS NULL). A self-join joins a table to itself under two aliases, for example an employee and their manager.

whyWhy it matters

Join mistakes do not raise errors; they return plausible numbers. A one-to-many join before a SUM double-counts. A filter in the wrong clause drops customers who never ordered. Knowing the join semantics is how you notice.

Use caseReal-world scenario

Marketing asks for every customer in Berlin with their number of orders, including people who have never ordered. That is a LEFT JOIN plus a count of a right-side column, not count(*).

Try itExamples

Run and edit these in the interactive lesson.

INNER JOIN: orders with their customer
SELECT o.id, o.created_at::date AS day, c.name, c.city, o.total
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 5;
LEFT JOIN keeps customers with no orders
SELECT c.id, c.name, count(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.city = 'Berlin'
GROUP BY c.id, c.name
ORDER BY orders, c.id
LIMIT 8;
count(o.id) counts only matched orders; count(*) would report 1 for customers with none.

Both queries try to list every Berlin customer with their cancelled orders. Compare the row counts.

The filter that breaks a LEFT JOIN
SELECT count(*) AS where_filter
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.city = 'Berlin' AND o.status = 'cancelled';

SELECT count(*) AS on_filter
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'cancelled'
WHERE c.city = 'Berlin';
In the first query, customers without a cancelled order have o.status NULL, the WHERE condition is unknown, and they vanish.
Semi-join and anti-join
-- customers who reviewed something
SELECT count(*) AS reviewers
FROM customers c
WHERE EXISTS (SELECT 1 FROM reviews r WHERE r.customer_id = c.id);

-- products that were never ordered
SELECT p.sku, p.name
FROM products p
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.product_id = p.id)
ORDER BY p.sku;
Self-join: employee and manager
SELECT e.name AS employee, e.title, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id
WHERE e.dept = 'Engineering'
ORDER BY e.id;

USING joins on same-named columns and outputs the column once. NATURAL joins on every column name the tables share, which breaks the day someone adds a column called created_at to both tables.

USING and NATURAL
SELECT order_id, count(*) AS lines
FROM order_items
JOIN (SELECT id AS order_id FROM orders WHERE status = 'pending' LIMIT 3) p USING (order_id)
GROUP BY order_id
ORDER BY order_id;

internalsUnder the hood

The planner recognises EXISTS and NOT EXISTS as semi- and anti-joins and can execute them with a hash, merge or nested-loop algorithm; you will see Hash Semi Join or Hash Anti Join in plans. The performance level covers how it chooses.

Avoid / preferDo this, not that

Avoid
SELECT c.name, o.id
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';
Prefer
SELECT c.name, o.id
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid';

A WHERE condition on the outer-joined table removes the NULL-extended rows, quietly turning the LEFT JOIN into an inner join.

Avoid
SELECT * FROM orders, customers WHERE orders.total > 100;
Prefer
SELECT * FROM orders JOIN customers ON customers.id = orders.customer_id
WHERE orders.total > 100;

A comma join without a join condition is a cartesian product: 10,000 orders times 2,000 customers is 20 million rows. Explicit JOIN ... ON makes a missing condition obvious.

PitfallsCommon mistakes

  • Filtering a right-side column of a LEFT JOIN in WHERE. Move the condition into ON, or test IS NULL deliberately.
  • Summing a parent column after joining to a child table (for example sum(o.total) after joining order_items). Each order is counted once per line item. Aggregate the child first, then join.
  • Using count(*) with a LEFT JOIN to count children. It counts the NULL-filled row as 1.

LimitsWhen not to use it

Avoid NATURAL JOIN outside throwaway queries: it joins on every column with the same name, so adding an unrelated created_at column to both tables silently changes the result.

SecuritySecurity notes

A view runs with its owner's privileges by default, so a view that joins a sensitive table can expose it to roles without access to that table. Use security_invoker = true on views when callers' privileges and row-level security should apply.

ChallengeTry it yourself

Count reviews written by customers who never ordered the product they reviewed.

Show one solution
One solution
SELECT count(*) AS reviews_without_purchase
FROM reviews r
WHERE NOT EXISTS (
  SELECT 1
  FROM orders o
  JOIN order_items i ON i.order_id = o.id
  WHERE o.customer_id = r.customer_id AND i.product_id = r.product_id
);

InterviewQuestions you should be able to answer

What is the difference between putting a condition in ON versus WHERE for a LEFT JOIN?
ON decides which right-side rows match; unmatched left rows are still kept with NULLs. WHERE filters the joined result, so a condition on a right-side column rejects the NULL-filled rows and the query behaves like an inner join. For inner joins the two are equivalent.
How do you find rows in A with no match in B, and which form do you prefer?
NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id) is the preferred anti-join: it is NULL-safe and the planner turns it into a Hash or Merge Anti Join. LEFT JOIN b ... WHERE b.a_id IS NULL is equivalent. Avoid NOT IN (SELECT a_id FROM b): if the subquery returns any NULL, the predicate is never true and you get zero rows.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation