Joins combine rows from two or more tables based on related columns. They are the mechanism behind nearly every report, dashboard, and ORM eager-load — and the source of duplicated rows, missing rows, and slow queries when misunderstood. This guide builds join intuition from set diagrams through real schemas, shows each join type with runnable SQL, and covers the mistakes that inflate result sets or hide data bugs until production.
Sample schema
We will use a small e-commerce database throughout:
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INT REFERENCES customers(id),
total_cents INT NOT NULL,
placed_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INT REFERENCES orders(id),
product_id INT NOT NULL,
quantity INT NOT NULL
);Sample data:
| customers | id | name |
|---|---|---|
| 1 | Ada | |
| 2 | Grace | |
| 3 | Lin |
Ada has two orders; Grace has one; Lin registered but never ordered.
INNER JOIN: only matching rows
INNER JOIN returns rows where the join condition is true in both tables. Non-matching rows disappear.
SELECT c.name, o.id AS order_id, o.total_cents
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
ORDER BY c.name, o.id;| name | order_id | total_cents |
|---|---|---|
| Ada | 101 | 4500 |
| Ada | 102 | 1200 |
| Grace | 103 | 8900 |
Lin does not appear — she has no orders. Use INNER JOIN when you only care about entities that have a relationship on both sides ("customers who placed at least one order").
Implicit vs explicit joins
Old-style comma syntax:
SELECT c.name, o.id
FROM customers c, orders o
WHERE o.customer_id = c.id;Prefer explicit JOIN ... ON — it separates relationship logic from filter logic and prevents accidental cross joins when someone forgets the WHERE clause.
LEFT JOIN: preserve the left table
LEFT [OUTER] JOIN keeps every row from the left table. When no match exists on the right, right-side columns are NULL.
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.name, o.id NULLS FIRST;| name | order_id |
|---|---|
| Ada | 101 |
| Ada | 102 |
| Grace | 103 |
| Lin | NULL |
This is the join for "all customers, including those without orders." Count carefully: Lin appears once with NULL, not zero times.
LEFT JOIN for existence checks
Find customers who never ordered:
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;Alternative with NOT EXISTS (often same plan, clearer intent):
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);RIGHT JOIN and FULL JOIN
RIGHT JOIN mirrors LEFT JOIN but preserves the right table. It is rare in practice because you can rewrite it as a LEFT JOIN by swapping table order:
-- These are equivalent
SELECT * FROM orders o RIGHT JOIN customers c ON ...
SELECT * FROM customers c LEFT JOIN orders o ON ...FULL OUTER JOIN keeps unmatched rows from both sides:
SELECT c.name, o.id
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id;Useful for data reconciliation ("show me customers without orders AND orders pointing to deleted customers"). PostgreSQL, SQL Server, and Oracle support FULL OUTER; MySQL does not — emulate with UNION of LEFT and RIGHT joins.
Joining three or more tables
List every order with customer name and line-item count:
SELECT
c.name,
o.id AS order_id,
COUNT(oi.id) AS line_items
FROM orders o
INNER JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.name, o.id
ORDER BY o.id;Join order matters for readability, not for INNER JOIN correctness. For OUTER joins, place driving tables first and think about which table's rows you must preserve.
Format complex queries with /tools/sql-formatter before code review so reviewers spot missing ON clauses quickly.
The duplicate row trap
Joining one-to-many relationships multiplies rows:
-- BAD for "total revenue per customer" without aggregation
SELECT c.name, oi.quantity
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id;Ada with 2 orders × 3 items each = 6 rows. Summing o.total_cents without GROUP BY double-counts revenue.
Fix with aggregation:
SELECT c.name, SUM(o.total_cents) AS revenue_cents
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;Or use a subquery / CTE to pre-aggregate before joining:
WITH order_totals AS (
SELECT customer_id, SUM(total_cents) AS revenue
FROM orders
GROUP BY customer_id
)
SELECT c.name, COALESCE(ot.revenue, 0) AS revenue_cents
FROM customers c
LEFT JOIN order_totals ot ON ot.customer_id = c.id;CROSS JOIN: intentional Cartesian product
CROSS JOIN pairs every row in A with every row in B — no condition:
SELECT d.date, p.product_id
FROM calendar_dates d
CROSS JOIN products p;Useful for generating combinations (dates × products for inventory grids). Accidental cross joins (missing join condition) produce enormous result sets and melt databases.
Self joins
Join a table to itself for hierarchical data:
CREATE TABLE employees (
id INT PRIMARY KEY,
name TEXT,
manager_id INT REFERENCES employees(id)
);
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;Alias tables clearly (e, m) — self joins become unreadable without them.
Semi-joins and anti-joins
EXISTS implements a semi-join — return left rows with at least one match, without duplicating:
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.total_cents > 10000
);NOT EXISTS is an anti-join. These patterns often outperform IN (SELECT ...) on large tables because optimizers short-circuit after the first match.
NULL behavior in join conditions
NULL = NULL is unknown, not true. Rows with NULL foreign keys never match INNER JOIN:
-- Orphan order with customer_id NULL won't join to any customer
SELECT * FROM orders o
INNER JOIN customers c ON c.id = o.customer_id;Use IS NOT DISTINCT FROM in PostgreSQL when NULL should match NULL:
ON c.id IS NOT DISTINCT FROM o.customer_idFilter predicates belong in WHERE or ON deliberately:
-- Move filter into ON to preserve LEFT JOIN semantics
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id AND o.placed_at >= '2026-01-01';Putting o.placed_at >= '2026-01-01' in WHERE converts the LEFT JOIN into an effective INNER JOIN for dated orders.
Performance basics
Indexes on foreign key columns (orders.customer_id, order_items.order_id) are essential. Without them, the database nested-loops or hash-scans entire tables.
Run EXPLAIN (ANALYZE, BUFFERS) on PostgreSQL to see join strategy:
EXPLAIN ANALYZE
SELECT c.name, COUNT(o.id)
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;Paste plans into /tools/sql-explain when sharing with teammates. Look for Seq Scan on large tables where you expected Index Scan.
Build exploratory joins visually with /tools/sql-query-builder, then paste the generated SQL into your migration or ORM raw query.
Join vs subquery: when to use which
Modern optimizers often rewrite subqueries and joins to the same plan, but readability differs.
Use joins when you need columns from both tables in the result:
SELECT c.name, o.total_cents
FROM customers c
JOIN orders o ON o.customer_id = c.id;Use subqueries in SELECT for scalar aggregates without grouping the outer query:
SELECT c.name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS order_count
FROM customers c;Use CTEs (WITH) when the same subquery appears twice or when step-by-step logic aids maintenance:
WITH recent_orders AS (
SELECT * FROM orders WHERE placed_at >= NOW() - INTERVAL '30 days'
)
SELECT c.name, COUNT(ro.id)
FROM customers c
LEFT JOIN recent_orders ro ON ro.customer_id = c.id
GROUP BY c.id, c.name;If a join query returns unexpected duplicates, sketch the table sizes on paper: 1 customer × N orders × M items = N×M rows before aggregation.
Join debugging checklist
- Result row count plausible? (Cartesian product check)
- OUTER JOIN filters in
ON, notWHERE, unless intentional - Aggregations use
GROUP BYon non-aggregated selected columns - Foreign key columns indexed
- NULL FK rows handled or excluded explicitly
- Query plan reviewed for sequential scans on hot paths
Conclusion
Pick the join type based on which rows you must keep: INNER for matches only, LEFT for "all from A plus matches from B," FULL for reconciliation. Watch for row multiplication when traversing one-to-many relationships, and validate row counts before trusting aggregated numbers.
Next steps: Format your team's top ten reporting queries with /tools/sql-formatter, index every join column, and continue with the PostgreSQL query optimization guide when joins show up in slow query logs.
Try these free tools
Put what you learned into practice — no signup required.