Skip to content

SQL Joins in PostgreSQL, Explained Properly

Aman Kumar Singh5 min read
Part 5 of 40From the PostgreSQL Masterclass series
SQL Joins in PostgreSQL, Explained Properly — article by Aman Kumar Singh

Joins are where SQL stops being a lookup language and starts being a query language. Most developers know INNER JOIN and stop there, then get surprised when rows go missing, or duplicate, or a report shows fewer customers than actually exist. Almost all of those surprises come from not being precise about which join you meant.

This is part 5 of the PostgreSQL Masterclass. With the schema and constraints in place, we can start asking real questions of the data, and every interesting question eventually needs a join.

The mental model that makes joins click

A join takes two tables and produces rows by matching them on a condition. The join type decides what happens to rows that don't find a match. That's the entire concept. Everything else is a variation on "what do I do with the unmatched rows."

Here are two small tables to work with:

CREATE TABLE customers (
  id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name text NOT NULL
);

CREATE TABLE orders (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id BIGINT REFERENCES customers(id),
  total       numeric(12, 2) NOT NULL
);

Say you have 3 customers and one of them has placed no orders. How that customer appears in your results depends entirely on the join you pick.

INNER JOIN: only the matches

INNER JOIN returns rows where the condition matches on both sides. Unmatched rows on either side vanish.

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

The customer with no orders does not appear at all. That's correct when you want "customers who have ordered," and it's a bug when you meant "all customers, with their orders if any." The word INNER is optional in Postgres, so a bare JOIN is an inner join. Being explicit costs nothing and reads more clearly.

LEFT JOIN: keep everything on the left

LEFT JOIN keeps every row from the left table and fills in NULL where the right side has no match.

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

Now the orderless customer shows up once, with NULL for total. This is the join you want for "list all X, with their related Y." It's also the source of a classic mistake: filtering the right table in the WHERE clause instead of the ON clause.

-- BUG: this quietly turns the LEFT JOIN back into an INNER JOIN
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.total > 100;

-- correct: put the condition on the join
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.total > 100;

In the first query, the orderless customer has NULL for o.total, and NULL > 100 is not true, so the WHERE clause drops them. You asked for a left join and got an inner join's result. When you filter the right-hand table of a left join, decide whether the condition belongs in ON (keep unmatched left rows) or WHERE (a genuine post-join filter).

RIGHT and FULL: less common, still useful

RIGHT JOIN is a LEFT JOIN with the tables swapped, so it keeps every row from the right table. Most people rewrite it as a left join for readability, since reading top-to-bottom left-to-right is easier when the preserved table comes first.

FULL OUTER JOIN keeps unmatched rows from both sides, filling NULL on whichever side is missing. It's the right tool for reconciliation: finding rows in table A with no match in B and rows in B with no match in A, in one pass.

-- find customers with no orders AND orders with no customer, together
SELECT c.name, o.id AS order_id
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id
WHERE c.id IS NULL OR o.id IS NULL;

The duplicate-row trap

Here's the join surprise that bites people in production. When you join to a table that has many rows per key, each matching row multiplies the output. Join a customer to their 5 orders and that customer's name appears 5 times. Now sum something from the customer side and you've counted it 5 times.

-- WRONG: if a customer has many orders, their credit is counted once per order
SELECT sum(c.account_credit)
FROM customers c
JOIN orders o ON o.customer_id = c.id;

The fix is to aggregate before joining, so each side contributes one row per key:

SELECT c.name, c.account_credit, o.order_count
FROM customers c
JOIN (
  SELECT customer_id, count(*) AS order_count
  FROM orders
  GROUP BY customer_id
) o ON o.customer_id = c.id;

Whenever a total looks inflated after adding a join, this is almost always why. A join fanned out the rows and an aggregate double-counted them.

Joining more than two tables

Real queries join several tables, and the same rules apply pairwise. Postgres joins left to right, applying each ON condition as it goes:

SELECT c.name, o.id AS order_id, oi.product_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
WHERE c.name = 'Acme Inc';

One thing worth knowing: the order you write joins does not force the order Postgres executes them. The query planner reorders joins to run the cheapest plan it can find, using your indexes and table statistics. You write joins for correctness and readability; the planner handles efficiency. We'll get into reading its decisions when we cover EXPLAIN later in the series.

A short checklist

When a join result looks wrong, run through this:

  • Missing rows you expected? You probably wanted a LEFT JOIN, or a right-table filter moved into WHERE and dropped your unmatched rows.
  • Duplicate rows or inflated sums? A join to a one-to-many table fanned out the rows; aggregate the many side first.
  • Slow join? That's an indexing and planning question, and index the join keys first.

Joins are not hard once you hold the core idea: pick the type by what should happen to unmatched rows, and watch for one-to-many relationships inflating your aggregates. Get those two right and the rest is detail.

Next, we stay with querying and look at aggregations: GROUP BY, HAVING, and the aggregate functions that turn rows into summaries.

Key takeaways

  • Choose a join type by what should happen to unmatched rows: `INNER` drops them, `LEFT` keeps the left side.
  • Filtering the right table of a `LEFT JOIN` in `WHERE` silently turns it into an inner join; put the condition in `ON` instead.
  • `FULL OUTER JOIN` is the reconciliation tool for finding unmatched rows on both sides at once.
  • Joining to a one-to-many table multiplies rows and inflates aggregates; aggregate the many side before joining.
  • You write joins for correctness; the planner reorders them for speed, so index the join keys.

Frequently asked questions

What's the difference between INNER JOIN and LEFT JOIN?

`INNER JOIN` returns only rows that match on both tables. `LEFT JOIN` returns every row from the left table and fills `NULL` where the right table has no match. Use `LEFT JOIN` when you want all of one side regardless of matches.

Why did my LEFT JOIN drop rows I expected to keep?

You likely filtered the right table in the `WHERE` clause. Since unmatched rows have `NULL` on the right, a `WHERE` condition on those columns removes them, turning the left join into an inner join. Move the condition into the `ON` clause.

Why is my SUM too large after adding a join?

Joining to a table with many rows per key multiplies the output rows, so a value from the other side gets counted once per matched row. Aggregate the many side in a subquery first, then join the single result.

Does the order I write joins affect performance?

No. The PostgreSQL planner reorders joins to find the cheapest execution plan using your indexes and statistics. Write joins for clarity; make sure the join keys are indexed for speed.

When should I use FULL OUTER JOIN?

For reconciliation, when you need unmatched rows from both tables in one result, such as finding records in A missing from B and records in B missing from A simultaneously.

Related articles

Views in PostgreSQL: When They Help and When They Hurt — Aman Kumar Singh
PostgreSQL Query Optimization Techniques — Aman Kumar Singh
CTEs and Recursive Queries in PostgreSQL — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.