Why does a GROUP BY report skip days with no sales, and how do you emit a row for every day?
answer
- groups come from the data, not the calendar
- no rows means no key means no group
- aggregates cannot create absent groups
- the key domain must come from somewhere
- left join a calendar to the aggregate
basics
~20 sGROUP BY forms groups only from key values present in its input, so a day with no rows produces no group and no output row. To show every day you must supply the key values from elsewhere — typically a calendar table left-joined to the aggregated result.
solid answer
~50 sGrouping is data-driven: a group exists only if at least one input row carries that key value. A day with no sales contributes no rows, so there is no group, so there is no `(day, 0)` row to emit. No aggregate can fix that, because aggregates run *inside* groups that already exist. The portable pattern is to bring the missing keys from a source that has them. Aggregate the facts in a CTE at the grain you want, then `LEFT JOIN` a calendar (or dimension) table to that result and `COALESCE` the measure to zero: ```sql WITH daily AS (SELECT CAST(sold_at AS DATE) d, SUM(amount) rev FROM sales GROUP BY CAST(sold_at AS DATE)) SELECT c.cal_date, COALESCE(daily.rev, 0) FROM calendar c LEFT JOIN daily ON daily.d = c.cal_date; ``` Aggregating first keeps the outer join simple and avoids the classic mistake of filtering the fact side after the join.
code
sql · 5 lines-- Symptom: days with no sales are absent, not zero
SELECT CAST(sold_at AS DATE) AS sale_day, SUM(amount) AS revenue
FROM sales
GROUP BY CAST(sold_at AS DATE)
ORDER BY sale_day;go deeper
Recall the cause: grouping only reports key values that exist in the data, so days without rows are simply absent. Do not expect a zero row to appear on its own.
Explain that no aggregate or HAVING trick can create an absent group, and be able to write the pattern: aggregate to the target grain in a CTE, left-join a calendar or dimension table, then COALESCE the measures.
Show you know why the naive join breaks — a fact-side filter after an outer join discards the rows added for empty periods — and that you decide deliberately between zero and NULL for periods that had no activity or had not started.
Own where densification belongs: a shared calendar and dimension tables, a convention for which axes get filled, and a rule that consumers are not each inventing their own gap-filling — otherwise the same metric renders differently in every dashboard.
## The rule behind the symptom `GROUP BY` partitions the rows it is given. A group exists if and only if some input row carries that key value. It follows that the result can only ever contain keys the data contains — grouping never invents a key, and there is no such thing as an empty group waiting to report zero. So a per-day revenue report shows the days that had sales. Days with none are absent, not zero. The same rule produces every version of this complaint: categories with no products, regions with no customers, statuses no order is currently in, hours with no errors. The distinction matters because "absent" and "zero" mean different things to a consumer. A chart drawn from absent rows joins the two neighbouring days with a straight line and hides the outage; a table shows fewer rows than the reader expected and quietly shifts week-over-week comparisons. ## Why no amount of aggregate tinkering fixes it Candidates often reach for the aggregate first: swap `SUM` for `COUNT(*)`, wrap it in `COALESCE`, add `HAVING COUNT(*) >= 0`. None of these can work, because every one of them operates on a group that already exists. `COALESCE(SUM(amount), 0)` turns a NULL measure into 0 *within* a group; it cannot create the missing group. `HAVING` filters existing groups. The missing key values simply are not in the query yet. ## Supplying the key domain The fix is structural: get the complete set of key values from somewhere that has it, and outer-join the aggregated facts to it. For time series that source is a **calendar table** — a small table with one row per date, which most warehouses keep anyway because it also carries fiscal periods, holidays and week numbers. Some engines can generate a series of dates on the fly with a built-in function, but those functions are engine-specific; a stored calendar table is the portable answer and is usually more useful besides. For a categorical dimension, the source is the dimension table itself: `categories`, `regions`, a status enum table. ```sql WITH daily AS ( SELECT CAST(sold_at AS DATE) AS sale_day, SUM(amount) AS revenue, COUNT(*) AS order_count FROM sales WHERE sold_at >= TIMESTAMP '2026-03-01 00:00:00' GROUP BY CAST(sold_at AS DATE) ) SELECT c.cal_date, COALESCE(d.revenue, 0) AS revenue, COALESCE(d.order_count, 0) AS order_count FROM calendar c LEFT JOIN daily d ON d.sale_day = c.cal_date WHERE c.cal_date >= DATE '2026-03-01' AND c.cal_date < DATE '2026-04-01' ORDER BY c.cal_date; ``` Three things make that shape work: 1. **Aggregate first, join second.** The CTE reduces the facts to one row per day before the join, so the outer join is one-to-at-most-one and there is no risk of the join multiplying rows before aggregation. 2. **The date range lives on the calendar side.** The reporting window is a property of the axis you want to draw, not of the facts. 3. **`COALESCE` supplies the zero.** Unmatched calendar rows get NULL measures from the outer join; converting them to `0` is a display decision you should make explicitly, since NULL and 0 are different claims — "we have no record" versus "we recorded nothing". ## The trap that turns the fix back into the bug If you skip the pre-aggregation and join raw facts to the calendar, any additional predicate on the fact side must be placed carefully: a fact-side condition written as an ordinary filter after the join discards the very rows the outer join created for empty days, silently restoring the original symptom. Aggregating in a CTE first sidesteps the question entirely, which is why it is the pattern worth memorising. ## Deciding whether you want the zeros at all Dense output is not automatically better. Filling every gap for a sparse key space is expensive and noisy — one row per product per day across a large catalogue is mostly zeros. Reasonable practice is to densify only the axis a chart plots (usually time), keep other dimensions sparse, and let the presentation layer handle gaps when the volume is large. And be honest about the semantics: for a period that has not happened yet, or a store that had not opened, a zero is a false statement and NULL is the truthful one. ## Summary Missing rows in a grouped report are not a bug in `GROUP BY`; they are its definition. Groups come from data. When the report needs the full key domain, the domain has to come from a table that holds it, joined to the pre-aggregated facts, with the empty slots given an explicit value.
- Why does COALESCE(SUM(amount), 0) not fix the missing days?Because it acts inside a group that already exists. It converts a NULL measure to zero for a day that had rows, but a day with no rows never formed a group in the first place, so there is nothing for the expression to be evaluated on. The missing key has to be supplied before aggregation results are read.
- Why aggregate in a CTE before joining the calendar rather than joining raw rows?It keeps the outer join one-to-at-most-one, so nothing fans out, and it removes the need to place fact-side predicates carefully — a filter applied to the fact side after an outer join discards the very rows added for empty days and restores the original bug.
- Should every missing group become a zero?Not always. Zero asserts "we measured nothing", while NULL asserts "we have no record", and for future periods or entities that did not exist yet only the second is true. Densifying a sparse multi-dimensional key space also explodes row counts, so it is usually worth densifying only the time axis.
- What supplies the key domain for a non-time dimension?The dimension table itself. Left-join `categories` or `regions` to the aggregated facts exactly as you would a calendar, and every declared member appears even with no activity. If no such table exists, the absence of a canonical list of members is itself the modelling gap to fix.
A tally clerk who only writes a line when a sale happens will never write a line saying "nothing sold today". If you want a row for every day, you start from a calendar and look each day up, rather than reading the sales log.
saying these in an interview costs you the question
- Says GROUP BY should emit a zero row for absent keys
- Tries HAVING COUNT(*) >= 0 to bring empty groups back
- Believes COALESCE around the aggregate creates the missing rows
- Joins raw facts to a calendar then filters the fact side afterwards
- Treats zero and NULL as interchangeable for periods with no data