Dark atmospheric scene with SQL query elements and detective motifs

Why COUNT(*) Lies After a LEFT JOIN (and How to Count Correctly)

October 25, 2026•Daniel•5 min read

You count orders per customer, and customers who never ordered show 1 instead of 0. Here's why: COUNT(*) counts rows. COUNT(column) counts non-NULL values in that column. After a LEFT JOIN, a customer with no orders still has a row, so COUNT(*) says 1 when the real answer is 0.

This post walks through why it happens, shows the fix, and covers the related traps that show up in the same kind of query: averages, sums, row multiplication and filters.

Reproducing the Bug

-- customers          -- orders
id | name             id  | customer_id | amount
---+-------           ----+-------------+-------
 1 | Alice            123 |           2 |  40.00
 2 | Bob              456 |           2 |  25.00

"How many orders does each customer have?" Alice has none, so we need a LEFT JOIN to keep her:

SELECT c.name, COUNT(*) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;
name  | orders
------+-------
Alice |      1   -- wrong!
Bob   |      2

Alice shows 1 order. She has never ordered anything.

Why It Happens

Look at the joined rows before grouping:

name  | o.id | amount
------+------+-------
Alice | NULL |   NULL
Bob   |  123 |  40.00
Bob   |  456 |  25.00

The LEFT JOIN kept Alice by giving her one row with NULL in every order column. COUNT(*) doesn't care what's in a row; it counts that the row exists. So Alice's group has 1 row, and COUNT(*) returns 1.

COUNT(o.id) counts only rows where o.id is not NULL. Alice's only row has o.id = NULL, so she gets 0.

The Fix

Count a column from the right-hand table that is never NULL when a real match exists. The primary key is the safest choice:

SELECT c.name, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;
name  | orders
------+-------
Alice |      0
Bob   |      2

A good rule: after a LEFT JOIN, never use COUNT(*) to count things from the right-hand table. Use COUNT(right_table.primary_key).

Why the primary key? If you count a nullable column like o.coupon_code, real orders without a coupon won't be counted either. Primary keys can't be NULL, so the only NULLs come from missing matches.

COUNT(*) answers "how many rows are in this group?" COUNT(o.id) answers "how many orders are in this group?" After a LEFT JOIN, those are different questions.

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

Alice gets NULL, not 0, because there are no non-NULL amounts to add. That NULL can break later calculations or show up as a blank in a dashboard. Wrap it:

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

"What's the average number of orders per customer?" There's no single column in the joined rows that answers this, and averaging an order column like AVG(o.amount) skips the NULL rows, so customers with no orders silently drop out. Aggregate per customer first, then average the result:

WITH per_customer AS (
  SELECT c.id, COUNT(o.id) AS orders
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.id
  GROUP BY c.id
)
SELECT AVG(orders) AS avg_orders_per_customer
FROM per_customer;   -- (0 + 2) / 2 = 1.0

Add a second one-to-many table, say support tickets, and counts can inflate:

SELECT c.name,
       COUNT(o.id) AS orders,
       COUNT(t.id) AS tickets
FROM customers c
LEFT JOIN orders  o ON o.customer_id = c.id
LEFT JOIN tickets t ON t.customer_id = c.id
GROUP BY c.name;

If Bob has 2 orders and 3 tickets, the JOIN produces 2 × 3 = 6 rows for him, and both counts come back as 6. Two quick fixes:

  • Use COUNT(DISTINCT o.id) and COUNT(DISTINCT t.id). Fine for counts, but it doesn't fix SUM.
  • Better: aggregate each table separately in its own CTE, then join the summaries, so each customer has at most one row from each side.
WITH o AS (SELECT customer_id, COUNT(*) AS orders, SUM(amount) AS spent
           FROM orders GROUP BY customer_id),
     t AS (SELECT customer_id, COUNT(*) AS tickets
           FROM tickets GROUP BY customer_id)
SELECT c.name,
       COALESCE(o.orders, 0)  AS orders,
       COALESCE(o.spent, 0)   AS spent,
       COALESCE(t.tickets, 0) AS tickets
FROM customers c
LEFT JOIN o ON o.customer_id = c.id
LEFT JOIN t ON t.customer_id = c.id;

Notice that COUNT(*) is safe inside those CTEs, because there's no LEFT JOIN there: every row really is an order or a ticket.

"How many paid orders does each customer have, including zero?" Putting WHERE o.status = 'paid' after the LEFT JOIN removes Alice entirely, because her NULL status fails the filter. Two correct options:

-- Option A: move the condition into ON
SELECT c.name, COUNT(o.id) AS paid_orders
FROM customers c
LEFT JOIN orders o
  ON o.customer_id = c.id AND o.status = 'paid'
GROUP BY c.name;

-- Option B: conditional aggregation
SELECT c.name,
       COUNT(CASE WHEN o.status = 'paid' THEN o.id END) AS paid_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;

Option B is handy when you want several counts at once, like paid, refunded and pending, in a single query. PostgreSQL also supports the shorter COUNT(o.id) FILTER (WHERE o.status = 'paid').

Checklist for "Count Per X" Queries

  • Need rows with zero? Use LEFT JOIN, with the "per X" table on the left.
  • Count the right table's primary key, not *.
  • Wrap SUMs in COALESCE(..., 0).
  • More than one one-to-many JOIN? Aggregate each table first.
  • Filtering the right table? Put the condition in ON, or use conditional aggregation.

Catch It in the Wild

It's easy to hit this bug when counting evidence per suspect: the suspect with no records looks like they have one. Try it yourself in Murder in Blackwater Bay, and see whether your counts hold up.

Adventurer, your next quest awaits: