A 'customers with no orders' report uses HAVING COUNT(*) = 0 and returns nothing — why?
answer
- Ask where the group for a missing customer would come from
- Grouping partitions rows that exist, nothing else
- HAVING can only remove, never add
- The smallest possible COUNT(*) for a returned group
basics
~20 sGROUP BY creates a group only where rows exist, so a customer with no orders produces no group for HAVING to test. HAVING can only remove groups, never invent them, and every surviving group has COUNT(*) of at least 1.
solid answer
~50 s`GROUP BY` builds groups out of the rows it is given. A customer with no orders contributes no row to `orders`, so no group is created for that customer and there is nothing for `HAVING` to inspect. `HAVING` is purely subtractive: it can discard groups, never conjure absent ones, which is why `COUNT(*)` of any returned group is at least 1 and `HAVING COUNT(*) = 0` returns an empty result by construction. The absent rows have to be reintroduced at row level, before grouping — typically by outer-joining the customer list to the orders and counting the order side, or by testing non-existence directly with `NOT EXISTS`. The same trap bites when a `WHERE` date filter removes a customer's only orders: the group disappears entirely rather than reporting zero, so a filter you want counted-but-not-eliminating must live inside the aggregate rather than in `WHERE`.
code
sql · 12 lines-- always returns nothing: no rows means no group to test
SELECT customer_id, COUNT(*)
FROM orders
GROUP BY customer_id
HAVING COUNT(*) = 0;
-- absence introduced before grouping
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name
HAVING COUNT(o.order_id) = 0;go deeper
Remember that a group only exists if rows created it, so a count of zero can never come back from grouping a single table. Finding 'none of these' starts from the table that does have the entities.
Explain that HAVING is subtractive and runs after grouping, and show the LEFT JOIN form with COUNT of the child key rather than COUNT(*), knowing why that distinction produces 0 instead of 1.
Recognise the production variant where a WHERE filter on the outer-joined side silently removes whole entities from a report, and be able to trace which stage dropped a row by stripping clauses back one at a time.
Set the expectation for reporting contracts: state whether a metric must emit explicit zeros for inactive entities, since dashboards and alerts read a missing row and a zero very differently.
## Groups come from rows The grouping step does not consult a catalogue of possible key values; it partitions the rows it actually received. If `orders` holds no row for customer 42, the grouping step never sees customer 42, never creates a group for it, and `HAVING` — which runs afterwards and only ever removes groups — has nothing to act on. That makes `HAVING COUNT(*) = 0` unsatisfiable over a single table: ```sql -- always empty SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id HAVING COUNT(*) = 0; ``` Every group exists precisely because at least one row produced it, so `COUNT(*)` is never 0 for a returned group. The query is not wrong in syntax; it is wrong in premise. ## Absence must enter at row level Because `HAVING` cannot add anything, the missing customers must be present *before* grouping. That means starting from the table that does have them: ```sql SELECT c.customer_id, c.name FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.name HAVING COUNT(o.order_id) = 0; ``` Now every customer contributes at least one row, so every customer forms a group. Note the aggregate: `COUNT(o.order_id)`, not `COUNT(*)`. The unmatched rows still exist — the outer join has filled the order columns with NULLs — so `COUNT(*)` would report 1 for a customer with no orders. Counting a column from the order side gives 0, because that form of `COUNT` ignores NULLs. The direct alternative expresses the intent without aggregating at all: ```sql SELECT c.customer_id, c.name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id ); ``` ## The same trap with a WHERE filter The subtler production version has nothing to do with `COUNT(*) = 0`. Consider "orders per customer in the last 90 days", where customers with none must still show a zero: ```sql -- customers whose only orders are old vanish from the report SELECT c.customer_id, COUNT(o.order_id) AS recent_orders FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE o.order_date >= DATE '2026-05-22' GROUP BY c.customer_id; ``` The `WHERE` predicate runs before grouping and eliminates the NULL-extended rows as well as the old orders, so those customers form no group at all and simply disappear — no zero, no row. The fix is to stop treating the date as a row filter over the joined result: either attach it to the join condition so unmatched customers survive, or keep every row and make the date part of the aggregate expression, so the group still exists and reports 0. ## Why HAVING keeps attracting the wrong predicate `HAVING` reads like "the final filter", so people reach for it whenever a report has too many or too few rows. The reliable check is to ask what the predicate needs in order to be evaluated. If it needs a group's summary — a count, a sum, a maximum — only `HAVING` can host it. If it needs a single row's column, it belongs in `WHERE` (or in the join condition, when preserving unmatched rows matters). And if the requirement is about rows that *do not exist*, no clause after `GROUP BY` can help at all, because the object being filtered was never created. ## Diagnosing this in the wild When a report is missing entities rather than showing zeros, trace it in stages. Run the query without `HAVING`: if the entity is still absent, the loss happened at or before grouping, so look at `WHERE` and at the join. If it appears with a wrong count, the aggregate expression is counting the wrong thing — frequently `COUNT(*)` where the outer-join side's column was meant. Only if the entity appears with a correct count does the fault actually lie in the `HAVING` predicate. ## What a strong answer sounds like State the invariant plainly — a group exists only because rows produced it, so `HAVING COUNT(*) = 0` can never match — then show that absence must be introduced before grouping, and note the related `WHERE`-filter version of the bug, which is the one that reaches production because it looks correct on data where every customer happens to have a recent order.
- In the LEFT JOIN version, why must the predicate be COUNT(o.order_id) = 0 rather than COUNT(*) = 0?The outer join produces one NULL-extended row for a customer with no orders, so that group holds one row and COUNT(*) reports 1. Counting a column from the order side instead ignores those NULLs and returns 0, which is what the report means by 'no orders'. The choice of aggregate expression carries the whole semantics here.
- How do you report zero for customers with no orders in the last 90 days, rather than dropping them?Keep the rows and move the date condition out of WHERE. Either put it in the LEFT JOIN's ON condition, so unmatched customers stay NULL-extended, or leave the join unfiltered and make the date part of the aggregate expression so it counts only recent orders. Both preserve the group, letting it legitimately report 0.
- A report is missing entities rather than showing zeros. How do you locate the stage that dropped them?Remove HAVING and re-run. If the entity is still absent, it was lost at or before grouping — inspect WHERE and the join type and condition. If it now appears with a suspicious count, the aggregate expression is wrong. Only an entity that appears with a correct count implicates the HAVING predicate itself.
saying these in an interview costs you the question
- Believes HAVING COUNT(*) = 0 finds missing entities
- Thinks GROUP BY produces a group per possible key value
- Uses COUNT(*) after a LEFT JOIN to count matches
- Puts the right-table filter in WHERE and loses the zeros
- Blames the HAVING predicate before checking the join