Definition
- **
COUNT(*)** – Counts every row in the group regardless of NULLs, because it counts row existence, not column values. COUNT(column)– Counts only rows where that column is not NULL, skipping NULL entries.COUNT(DISTINCT column)– Counts the distinct non-NULL values of that column, removing both NULLs and duplicates.- LEFT JOIN placeholder – When a parent has no children, the LEFT JOIN still produces one row with all child columns set to NULL.
- The trap –
COUNT(*)counts the NULL-filled placeholder row as 1, making a childless parent look like it has one child. - The fix – Use
COUNT(child.primary_key)so the NULL key is not counted and the parent correctly reports 0.
Priya at National Bank of India lists every account alongside its transactions using a LEFT JOIN so even accounts with no transactions still appear. For Pankaj's new FD account that has zero transactions, the LEFT JOIN prints one line with all transaction fields blank. If you count rows with COUNT(*), you get 1 – the blank line fooled you. If you count the transaction ID with COUNT(txn_id), the blank is NULL and not counted, so you correctly get 0.
Understanding the Concepts
What is COUNT(*)?
COUNT(*) counts every physical row in the group. It counts row existence, not column values, so it includes rows where all columns are NULL.
-- COUNT(*): counts all rows, including those with NULLs
SELECT COUNT(*) AS total_rows FROM transactions;
-- Returns the total number of rows, regardless of NULL values in any column
What is COUNT(column)?
COUNT(column) evaluates a specific column per row and counts only where it is not NULL. NULL values in that column are skipped.
-- COUNT(column): counts only non-NULL values
SELECT COUNT(txn_description) AS described_txns FROM transactions;
-- Skips rows where txn_description is NULL
What is COUNT(DISTINCT column)?
COUNT(DISTINCT column) applies the non-NULL filter and then removes duplicates, counting unique non-NULL values.
-- COUNT(DISTINCT column): unique non-NULL values
SELECT COUNT(DISTINCT account_id) AS unique_accounts FROM transactions;
-- How many different accounts have at least one transaction
How do the three COUNT forms compare?
For a column with values {A, A, B, NULL}:
| Expression | Result | Explanation |
|---|---|---|
| COUNT(*) | 4 | Counts all rows |
| COUNT(column) | 3 | Skips the NULL |
| COUNT(DISTINCT column) | 2 | Only A and B (unique, non-NULL) |
-- Demonstration with banking data
CREATE TABLE sample_txns (
txn_id SERIAL PRIMARY KEY,
category VARCHAR(20)
);
INSERT INTO sample_txns (category) VALUES ('DEBIT'), ('DEBIT'), ('CREDIT'), (NULL);
SELECT
COUNT(*) AS count_star, -- 4
COUNT(category) AS count_col, -- 3 (NULL skipped)
COUNT(DISTINCT category) AS count_dist -- 2 (DEBIT, CREDIT)
FROM sample_txns;
Expected output:
count_star | count_col | count_dist
------------+-----------+-----------
4 | 3 | 2
What is the LEFT JOIN COUNT trap?
A LEFT JOIN guarantees every left (parent) row appears at least once. When a parent has matching children, it appears once per child. When a parent has no children, it still appears exactly once, but the right-side (child) columns are all NULL.
-- The trap: COUNT(*) reports 1 for a childless parent
SELECT a.account_id, a.holder_name, COUNT(*) AS txn_count
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.account_id
GROUP BY a.account_id, a.holder_name
ORDER BY txn_count;
WRONG output: account_id | holder_name | txn_count ------------+----------------+---------- FD4001 | Pankaj Gupta | 1 ← WRONG! Pankaj has 0 transactions CUR3001 | Priya Sharma | 3 SAV2001 | Rajesh Kumar | 12
-- The fix: COUNT(child.primary_key) correctly reports 0
SELECT a.account_id, a.holder_name, COUNT(t.txn_id) AS txn_count
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.account_id
GROUP BY a.account_id, a.holder_name
ORDER BY txn_count;
CORRECT output: account_id | holder_name | txn_count ------------+----------------+---------- FD4001 | Pankaj Gupta | 0 ← Correct! CUR3001 | Priya Sharma | 3 SAV2001 | Rajesh Kumar | 12
Why does this happen?
The NULL-filled placeholder row from the LEFT JOIN is a real row in the result set. COUNT(*) counts row existence, so it counts the placeholder. COUNT(t.txn_id) evaluates the txn_id column, which is NULL on the placeholder row, and NULLs are not counted.
-- See the raw LEFT JOIN output before grouping
SELECT a.account_id, a.holder_name, t.txn_id, t.amount
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.account_id
WHERE a.account_id = 'FD4001';
Expected output: account_id | holder_name | txn_id | amount ------------+--------------+--------+-------- FD4001 | Pankaj Gupta | NULL | NULL ← placeholder row -- This single NULL row is what COUNT(*) counts as 1
How do you fix this in Java code?
package com.bank.service;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.Map;
public class AccountActivityService {
// CORRECT: count the child's key column, not COUNT(*)
public Map<String, Integer> getTransactionCountPerAccount(
Connection conn) throws SQLException {
String sql = """
SELECT a.account_id, a.holder_name, COUNT(t.txn_id) AS txn_count
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.account_id
GROUP BY a.account_id, a.holder_name
ORDER BY txn_count DESC
""";
Map<String, Integer> result = new LinkedHashMap<>();
try (PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.put(rs.getString("holder_name"), rs.getInt("txn_count"));
}
}
return result;
}
}
What about SUM and AVG over LEFT-joined children?
SUM and AVG already skip NULL values, so they behave correctly over LEFT-joined children. Only COUNT(*) has this trap.
-- SUM correctly returns NULL (not 0) for childless parents
SELECT a.account_id, SUM(t.amount) AS total_amount
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.account_id
GROUP BY a.account_id;
-- FD4001: total_amount = NULL (no transactions)
-- Use COALESCE(SUM(t.amount), 0) to convert NULL to 0
What are common mistakes?
Using COUNT(*) instead of COUNT(child.id) across a LEFT JOIN is the most common silent reporting bug. Another mistake is forgetting that SUM returns NULL (not 0) for empty groups.
-- WRONG: dormant account looks like it has 1 transaction
SELECT a.account_id, COUNT(*) AS txn_count
FROM accounts a LEFT JOIN transactions t ON t.account_id = a.account_id
GROUP BY a.account_id;
-- CORRECT: dormant account correctly shows 0
SELECT a.account_id, COUNT(t.txn_id) AS txn_count
FROM accounts a LEFT JOIN transactions t ON t.account_id = a.account_id
GROUP BY a.account_id;
What should a senior engineer know?
A senior engineer knows to always count a guaranteed-non-NULL column from the child table (typically its primary key), never COUNT(*), when counting across a LEFT JOIN. An alternative that avoids the trap entirely is a correlated subquery or a pre-aggregated child count joined back.
Visual Overview
┌─────────────────────────────────────────────────────┐ │ LEFT JOIN COUNT Trap │ ├─────────────────────────────────────────────────────┤ │ │ │ accounts transactions │ │ ┌──────────┐ ┌──────────┐ │ │ │ SAV2001 │──match──→│ TXN-042 │ │ │ │ │──match──→│ TXN-038 │ │ │ │ FD4001 │──no match→│ (NULL) │ ← placeholder │ │ └──────────┘ └──────────┘ │ │ │ │ After LEFT JOIN + GROUP BY: │ │ │ │ SAV2001: 2 rows with real txn_ids │ │ FD4001: 1 row with NULL txn_id │ │ │ │ COUNT(*): SAV2001=2, FD4001=1 ← WRONG │ │ COUNT(txn_id): SAV2001=2, FD4001=0 ← CORRECT │ │ │ ├─────────────────────────────────────────────────────┤ │ Rule: COUNT(child.pk) across LEFT JOIN, never * │ └─────────────────────────────────────────────────────┘
Key Takeaway
The three COUNT forms differ only in how they treat NULLs and duplicates. COUNT(*) counts every physical row because it counts row existence, so it includes rows whose columns are entirely NULL. COUNT(column) counts only rows where that column is not NULL. COUNT(DISTINCT column) applies the non-NULL filter and removes duplicates. For a column holding A, A, B, and a NULL, COUNT(*) is four, COUNT(column) is three, and COUNT(DISTINCT column) is two.
The LEFT JOIN trap follows directly. A LEFT JOIN guarantees that every parent row appears at least once. When a parent has no children, it still produces one row with all child columns set to NULL as placeholders. So COUNT(*) on this group reports 1 for a childless parent, which is wrong. At National Bank of India, this exact bug once made Pankaj's dormant FD account look like it had one transaction and triggered a false activity alert.
The fix is to count a column that is guaranteed non-NULL on real child rows – almost always the child's primary key. COUNT(t.txn_id) skips the NULL placeholder and correctly reports 0 for the childless parent, while parents with real children count identically to COUNT(*) because their child keys are never NULL.
The senior rule Rajesh always states is that whenever you count the many-side across a LEFT JOIN, you count the child's key column, never COUNT(*), because the placeholder NULL row of a childless parent still counts as one.
Remember one sentence: Over a LEFT JOIN, count the child's key column – COUNT(child.id) – never COUNT(*), because the placeholder NULL row of a childless parent still counts as one.
---