COUNT(*) vs COUNT(column): Why Your Counts Differ
Count rows, missing values and distinct values, then fix a misleading LEFT JOIN total in a live PostgreSQL lab.
A report says you have five members, but only three email addresses. Both numbers came from the same table. Which count is wrong?
Neither has to be wrong. COUNT(*) counts input rows; COUNT(email) counts rows whose email is non-null. The difference is missing data. COUNT(DISTINCT email) adds another distinction: repeated addresses count only once.
In this lab, you will inspect five members, compare all three expressions, group the results by team, and use the same rules to fix a ticket report after a LEFT JOIN. Every example runs against a small, isolated PostgreSQL database in the terminal beside this article.
Use Run all steps in each section, or enter the SQL yourself. The lab is already seeded; Reset terminal restores the starting data.
Count rows and known email values
The lab has five members. Ada and Grace share an address, Linus has another, and two members have NULL in email. The first query displays NULL as [NULL] so you can see the missing values. COALESCE changes only the display; the stored values stay untouched.
The second query asks four different questions:
| Expression | Question | Result |
|---|---|---|
COUNT(*) | How many member rows? | 5 |
COUNT(email) | How many rows have a non-null email? | 3 |
COUNT(*) - COUNT(email) | How many rows have a NULL email? | 2 |
COUNT(DISTINCT email) | How many different non-null emails? | 2 |
The shared address counts twice in COUNT(email), once in COUNT(DISTINCT email). Neither counts NULL email values. An empty string, however, is a non-null text value and would count. Whether it is a usable address is a separate validation rule.
Avoid replacing the missing values inside the count just to make the numbers agree. COUNT(COALESCE(email, 'unknown')) would count all five members, because the replacement makes every expression result non-null. That changes the question.
From the shell, open the demo database (PGDATABASE=blog_lab):
psqlThen run this at the SQL prompt:
SELECT id, name, team, COALESCE(email, '[NULL]') AS email
FROM members ORDER BY id;SELECT COUNT(*) AS members, COUNT(email) AS email_values,
COUNT(*) - COUNT(email) AS missing_emails,
COUNT(DISTINCT email) AS distinct_emails
FROM members;Expected: members = 5; email_values = 3; missing_emails = 2; distinct_emails = 2.
Compare the counts within each group
GROUP BY applies the same counting rules separately to each team. Engineering has three members, but only two email values. Those two values are the same address, so its distinct count is one.
This matters when naming a report column. A label such as “members” should count member rows; “email addresses” could mean populated values or unique addresses. Pick the expression that matches the metric and give it a precise alias.
WHERE runs before grouping. If you add WHERE email IS NOT NULL, the two members without email disappear before any count is computed. COUNT(*) then counts only the remaining members, and no longer measures the full team size.
SELECT team, COUNT(*) AS members, COUNT(email) AS email_values,
COUNT(DISTINCT email) AS distinct_emails
FROM members
GROUP BY team
ORDER BY team;Expected: Engineering: 3 members, 2 email values, 1 distinct email. Research: 2 members, 1 email value, 1 distinct email.
Count matching rows after a LEFT JOIN
Now build a support report that includes members who have no tickets. Ada has two tickets, Grace has one, and the other three members have none. A nullable closed_at timestamp records whether a ticket has been closed.
LEFT JOIN preserves every member. For a member without a matching ticket it produces one output row with NULL in the ticket columns. COUNT(*) counts that row, giving a misleading “one ticket” if you use it as the ticket total.
Count t.id instead. It is the ticket table's primary key, so it is non-null for every real ticket and NULL on the unmatched join row. COUNT(t.closed_at) answers another question again: how many of those tickets have a closing timestamp? Grace's ticket exists even though its closed_at is NULL.
| Member | COUNT(*) | COUNT(t.id) | COUNT(t.closed_at) |
|---|---|---|---|
| Ada | 2 | 2 | 1 |
| Grace | 1 | 1 | 0 |
| Linus | 1 | 0 | 0 |
| Margaret | 1 | 0 | 0 |
| Barbara | 1 | 0 | 0 |
You do not need COALESCE around COUNT(t.id) to get zero here. The preserved member group exists, and none of its t.id values is non-null.
If you later join another one-to-many table, inspect the joined rows again: each ticket may appear multiple times. Counting a non-null key solves the unmatched-row problem, but does not remove duplicated matches.
SELECT m.name, COUNT(*) AS joined_rows,
COUNT(t.id) AS tickets, COUNT(t.closed_at) AS closed_tickets
FROM members m
LEFT JOIN tickets t ON t.member_id = m.id
GROUP BY m.id, m.name
ORDER BY m.id;Expected: Ada: 2 joined rows, 2 tickets, 1 closed. Grace: 1, 1, 0. Linus, Margaret and Barbara: each 1 joined row, 0 tickets, 0 closed.
Check the empty-input case
WHERE false deliberately removes every input row. Without GROUP BY, the aggregate query still produces one summary row: both counts are zero.
With GROUP BY team there are no team groups to summarize, so the second query produces no rows. This is different from the LEFT JOIN example, where each member was preserved and therefore had a group.
If a dashboard needs every team displayed even when no members match a filter, start from a table of teams and preserve it with a LEFT JOIN. Count a non-null member key, just as you counted the ticket key in the previous example.
SELECT COUNT(*) AS members, COUNT(email) AS email_values
FROM members
WHERE false;SELECT team, COUNT(*) AS members
FROM members
WHERE false
GROUP BY team;Expected: The first query returns one row with members = 0 and email_values = 0. The grouped query returns no rows.
Choose what you mean to count
Use COUNT(*) for input rows, COUNT(column) for non-null values, and COUNT(DISTINCT column) for different non-null values. After a LEFT JOIN, count a non-null key from the matching table when you need the number of matches.
Before changing a count, inspect a few input rows and say the metric aloud: “members,” “members with an email,” “unique emails,” or “tickets per member.” Those are different questions.
Continue with why LEFT JOIN rows disappear. For the formal definitions, see PostgreSQL's aggregate functions documentation.
Try one more experiment: give Barbara an email address already used by Ada. Before rerunning the counts, predict which totals change and which stay the same.