How to Use LEFT JOIN in PostgreSQL

How to Use LEFT JOIN in PostgreSQL

If I had to pick the single most useful join type I use in day-to-day PostgreSQL work, it’s LEFT JOIN, without question. Almost every real-world query I write starts with “give me all of this main table, plus whatever related data exists — even if it doesn’t exist.” That’s exactly what LEFT JOIN is built for, and once it clicks, you’ll notice it becomes your default join type for most reporting and application queries.

Let’s go through it properly: what it does, how to write it, and the patterns I rely on constantly.

What Is a LEFT JOIN?

A LEFT JOIN (also written LEFT OUTER JOIN) returns all rows from the left-hand table, along with matching rows from the right-hand table. Where there’s no match, the right-hand table’s columns come back as NULL — but the left-hand row is never dropped.

This is different from an INNER JOIN, which only returns rows that have a match on both sides.

Setting Up an Example

CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100)
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INT REFERENCES customers(id),
    amount NUMERIC(10,2)
);

INSERT INTO customers (name) VALUES
('Ayesha'), ('Hamza'), ('Zara'), ('Usman');

INSERT INTO orders (customer_id, amount) VALUES
(1, 250.00),
(1, 100.00),
(2, 75.00);

Notice that Zara and Usman have never placed an order.

Basic Syntax

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

Result:

nameamount
Ayesha250.00
Ayesha100.00
Hamza75.00
ZaraNULL
UsmanNULL

Every customer shows up — even the ones with zero orders. This is exactly what an INNER JOIN would not give you; it would silently drop Zara and Usman entirely.

LEFT JOIN vs INNER JOIN — The Core Difference

This is the distinction that matters most. An INNER JOIN only shows rows where both tables have matching data:

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

This drops Zara and Usman completely, since they have no matching order rows. If your goal is “show me everyone, with their order data if it exists,” INNER JOIN gives you the wrong answer — you’d never even know those customers existed in the output. LEFT JOIN is the fix.

Finding Rows With No Match (The Classic Pattern)

One of the most common real-world uses of LEFT JOIN is finding rows in the left table that have no corresponding match at all:

SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;

This returns only Zara and Usman — customers who have never placed an order. I use this pattern constantly: finding users who never verified their email, products that have never been sold, articles with zero comments. The formula is always the same: LEFT JOIN, then WHERE right_table.id IS NULL.

The WHERE Clause NULL Trap

This is the single most common mistake with LEFT JOIN, and I made it more than once before it stuck. If you filter on a column from the right-hand table using WHERE, you can accidentally turn your LEFT JOIN back into an effective INNER JOIN:

-- BUG: silently becomes an inner join
SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.amount > 50;

Since Zara and Usman have NULL for o.amount, and NULL > 50 is never true, they get filtered out — even though the whole point of the LEFT JOIN was to keep them. If you want to filter the joined table’s data while still preserving unmatched left rows, move the condition into the ON clause:

SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id AND o.amount > 50;

Now every customer still appears; only orders under $50 get excluded from the join match, not the customer rows themselves.

LEFT JOIN With Aggregation

Combining LEFT JOIN with GROUP BY is extremely common for reports that need zero-value rows included, not silently dropped:

SELECT c.name, COUNT(o.id) AS total_orders, COALESCE(SUM(o.amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.name
ORDER BY total_spent DESC;

A few details worth calling out here:

  • COUNT(o.id) correctly returns 0 for customers with no orders, because COUNT() ignores NULLs.
  • SUM(o.amount) would return NULL (not 0) for customers with no orders, since summing nothing produces NULL — that’s why I wrap it in COALESCE(..., 0).

Multiple LEFT JOINs

You can chain as many LEFT JOINs as needed:

SELECT c.name, o.amount, p.name AS product_name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN products p ON p.id = oi.product_id;

Each LEFT JOIN preserves rows from everything joined so far, even if later joins don’t find a match. This is common when building a full picture of a customer’s activity across several related tables, where any given customer might be missing data at any stage of the chain.

LEFT JOIN With Multiple Conditions

Sometimes you need more than a simple equality match:

SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o
    ON c.id = o.customer_id
    AND o.amount > 100
    AND o.status = 'completed';

This keeps every customer, but only matches orders that are both over $100 and marked completed — customers with no such order still show up with NULL values, rather than being excluded entirely.

The Join Fan-Out Problem in Detail

I mentioned briefly that chaining multiple LEFT JOINs can multiply your row count, but this deserves a closer look because it’s a genuinely common source of subtle bugs — especially in financial reports where an inflated row count means inflated totals.

CREATE TABLE order_items (
    id SERIAL PRIMARY KEY,
    order_id INT REFERENCES orders(id),
    product_name VARCHAR(100),
    line_amount NUMERIC(10,2)
);

Imagine each order has multiple line items. If I write:

SELECT c.name, SUM(o.amount) AS total_from_orders
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.name;

This is a bug waiting to happen. Because each order might have three or four line items, the order_items join duplicates each order row once per line item — meaning o.amount gets counted multiple times in the SUM(), silently inflating the customer’s total. This is called “join fan-out,” and it’s one of the most common causes of mysteriously wrong totals in reports built on top of joined data.

The fix is usually to aggregate each side separately before joining, or use SUM(DISTINCT ...) carefully (which has its own caveats), or restructure with a subquery:

SELECT c.name, order_totals.total_from_orders
FROM customers c
LEFT JOIN (
    SELECT customer_id, SUM(amount) AS total_from_orders
    FROM orders
    GROUP BY customer_id
) AS order_totals ON order_totals.customer_id = c.id;

By pre-aggregating orders into a subquery before joining, each customer only joins against a single summarized row, completely avoiding the fan-out problem. This is a pattern I now default to any time I’m joining a table that itself has a one-to-many relationship with another table further down the chain.

LEFT JOIN for “Latest Related Record” Lookups

A pattern I use constantly: finding each customer’s most recent order using LEFT JOIN combined with DISTINCT ON, which is a PostgreSQL-specific feature that’s genuinely elegant once you know it exists.

SELECT DISTINCT ON (c.id) c.name, o.amount, o.order_date
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.id, o.order_date DESC NULLS LAST;

DISTINCT ON (c.id) keeps only the first row per c.id group after sorting, which — combined with ORDER BY c.id, o.order_date DESC — gives exactly one row per customer, containing their most recent order (or NULL fields if they have no orders at all). This avoids the join fan-out problem entirely, since we’re explicitly collapsing down to one row per customer rather than aggregating.

Chaining Multiple Optional Relationships

A pattern I use constantly when building a single “profile summary” query — pulling together several genuinely optional pieces of related data, each of which might or might not exist for any given customer:

SELECT
    c.name,
    addr.city,
    sub.plan_name,
    loyalty.points_balance
FROM customers c
LEFT JOIN addresses addr ON addr.customer_id = c.id AND addr.is_primary = true
LEFT JOIN subscriptions sub ON sub.customer_id = c.id AND sub.status = 'active'
LEFT JOIN loyalty_accounts loyalty ON loyalty.customer_id = c.id;

Every one of these relationships is optional — a customer might not have a saved address yet, might not have an active subscription, and might never have signed up for the loyalty program. Using LEFT JOIN for all three means the query still returns every customer, with NULL filling in wherever a particular optional relationship doesn’t exist, rather than silently dropping customers who are missing any single piece of this profile data. This is a genuinely common shape for “build me a full customer profile” queries in real applications, and it’s worth noticing that each LEFT JOIN here also carries an extra condition in its ON clause (is_primary = true, status = 'active') — filtering which specific related row to match, while still preserving every customer regardless of whether that filtered match exists.

Common Use Cases

  • Finding “orphaned” or inactive records: customers with no orders, products never sold, users who never logged in.
  • Building complete reports where every entity must appear, even with zero related activity.
  • Optional relationships: showing a user’s profile picture if one exists, without excluding users who haven’t uploaded one.
  • Data completeness checks: identifying gaps between two related tables during audits or migrations.
  • Dashboards and summaries where zero-activity rows are just as meaningful as active ones.

LEFT JOIN vs NOT EXISTS for Finding Missing Records

I showed the LEFT JOIN ... WHERE right.id IS NULL pattern earlier for finding unmatched rows, but it’s worth knowing there’s an alternative using NOT EXISTS, and understanding when each is preferable:

-- LEFT JOIN approach
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;

-- NOT EXISTS approach
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

Both return identical results here. In my experience, NOT EXISTS is often slightly clearer to read when finding-missing-records is the only thing the query does, since it states the intent directly (“doesn’t exist”) rather than requiring the reader to understand the NULL-filtering trick. I reach for the LEFT JOIN version instead when I also need columns from the joined table in my output, or when I’m already building a larger query that uses LEFT JOIN for other purposes and want to stay consistent within that same query.

Performance-wise, PostgreSQL’s planner is generally smart enough to produce similar execution plans for both forms on well-indexed tables, so this really comes down to readability and context rather than a hard performance rule — though it’s always worth checking EXPLAIN ANALYZE if you’re unsure on a particular dataset.

Troubleshooting Tips

My LEFT JOIN is dropping rows I expected to keep. Check for a WHERE clause filtering on the right-hand table — move that condition into the ON clause instead if you need to preserve unmatched left rows.

COUNT() or SUM() giving wrong results after LEFT JOIN. Use COUNT(specific_column) from the joined table, not COUNT(*), and wrap SUM() in COALESCE(..., 0) to avoid NULL results for groups with no matches.

Getting duplicate rows after LEFT JOIN. This usually means the right-hand table has multiple matching rows per left-hand row (a one-to-many relationship), which is expected behavior — each match produces its own row. If you wanted one row per left-hand entity, aggregate with GROUP BY or use a subquery instead.

Query is slow on large tables. Make sure the join column is indexed, especially the foreign key column on the “many” side:

CREATE INDEX idx_orders_customer_id ON orders (customer_id);

Then verify with EXPLAIN ANALYZE.

Best Practices I Follow

  1. Default to LEFT JOIN whenever you need “all of this table, plus optional related data.”
  2. Never filter the joined table in WHERE if you need to preserve unmatched rows — use the ON clause instead.
  3. Use COUNT(column) and COALESCE(SUM(...), 0) to get accurate zero-values after aggregation.
  4. Index foreign key columns used in join conditions.
  5. Chain joins carefully and test incrementally when combining several LEFT JOINs in one query.
  6. Use the WHERE right.id IS NULL pattern whenever you specifically need to find unmatched/missing records.

Frequently Asked Questions

What’s the difference between LEFT JOIN and LEFT OUTER JOIN? Nothing — OUTER is optional syntax. LEFT JOIN and LEFT OUTER JOIN behave identically in PostgreSQL.

Does LEFT JOIN affect performance compared to INNER JOIN? Generally similar, assuming proper indexing. The performance difference, if any, is usually negligible compared to the impact of missing indexes or large unindexed table scans.

Can I use LEFT JOIN with more than two tables? Yes, chain as many as needed. Each join operates on the result of the previous ones, so unmatched rows continue to be preserved throughout the entire chain.

Why does my LEFT JOIN return more rows than my left table has? This happens when the right-hand table has multiple matches per left-hand row — a one-to-many relationship produces one output row per match, multiplying your row count. This is expected join behavior, not a bug.

How do I get exactly one row per left-hand entity after a LEFT JOIN? Aggregate with GROUP BY, or restructure using a subquery/CTE that pre-aggregates the right-hand table before joining.

Can a LEFT JOIN ever return fewer rows than the left table has? No — a LEFT JOIN always returns at least one row for every row in the left table, and never fewer. It can return more (if there are multiple matches on the right side), but it will never drop a left-hand row entirely, which is the entire point of using LEFT JOIN over INNER JOIN.

What happens if I LEFT JOIN a table to itself? This is a valid and common pattern called a self join, often used for hierarchical data like employees and their managers. Using LEFT JOIN instead of INNER JOIN in this case ensures top-level records (like employees with no manager) still appear in the results.

Wrapping Up

LEFT JOIN is the workhorse of everyday PostgreSQL querying. Anytime the shape of your question is “show me everything from this table, and whatever related data happens to exist,” this is the join you reach for. Get comfortable with the WHERE-clause NULL trap, learn the COALESCE/COUNT pattern for clean aggregation, and you’ll find LEFT JOIN becomes second nature — quietly doing the heavy lifting behind almost every dashboard, report, and data completeness check you’ll ever build.

Total
2
Shares

Leave a Reply

Previous Post
How to Use INNER JOIN in PostgreSQL

How to Use INNER JOIN in PostgreSQL

Next Post
How to Use RIGHT JOIN in PostgreSQL

How to Use RIGHT JOIN in PostgreSQL

Related Posts