skip to content

How do you use CROSS JOIN to report zero-sale days for every product in a date range?

level: seniorimportance: should knowfreq 40%

answer

  1. the missing rows were never in the table
  2. you cannot aggregate rows that do not exist
  3. manufacture the grid first, attach data second
  4. calendar CROSS JOIN products, then LEFT JOIN sales

basics

~20 s

CROSS JOIN a date list to the product list to manufacture every (day, product) pair, LEFT JOIN the sales table on both keys, then COALESCE the aggregate to 0 so pairs with no sale still report a row.

solid answer

~50 s

Aggregating the sales table alone can never show a zero: a day with no sale has no row, so `GROUP BY` produces no group for it. The fix is to manufacture the full grid first. `CROSS JOIN` a calendar (or generated date list) to the product list, giving one row per `(day, product)` combination — 31 days × 12 products = 372 rows — then `LEFT JOIN` the sales table on both the date and the product, keeping every grid row whether or not a sale matched. Finally wrap the aggregate in `COALESCE(SUM(s.amount), 0)` so unmatched pairs report zero rather than NULL. Keep both join keys in the `ON` clause: moving either into `WHERE` discards the unmatched rows you built the spine to preserve. Watch the grid size — it is a deliberate product, so a wide date range times a large dimension can be huge.

code

sql · 11 lines
sql
SELECT d.day,
       p.product_id,
       COALESCE(SUM(s.amount), 0) AS revenue
FROM calendar d
CROSS JOIN products p
LEFT JOIN sales s
       ON s.sold_on    = d.day
      AND s.product_id = p.product_id
WHERE d.day BETWEEN DATE '2026-01-01' AND DATE '2026-01-31'
GROUP BY d.day, p.product_id
ORDER BY d.day, p.product_id;

go deeper

for a junior

Understand why a day with no sales simply does not appear in a GROUP BY result, and that the missing days have to come from somewhere other than the sales table.

for a middle

Walk through the three-step shape: CROSS JOIN to build the grid, LEFT JOIN to attach facts, COALESCE to turn the empty aggregate into a zero. Explain why SUM over NULL-extended rows yields NULL.

for a senior

Show judgement about grid size and predicate placement — filter both factors before crossing, keep fact-side keys in ON, and say when zero-filling belongs in SQL versus the reporting layer.

for a principal

Argue the case for a shared calendar dimension and a single agreed definition of a zero-filled series, so dashboards, exports and downstream models cannot disagree about what an absent day means.

## The problem: rows that were never written Reports need to show absence. "Revenue by day and product for January" should list a zero for a product that sold nothing on the 14th, and a dashboard chart needs a point on every day or the line lies about the shape of the trend. But aggregation can only summarise rows that exist: ```sql SELECT sold_on, product_id, SUM(amount) AS revenue FROM sales WHERE sold_on BETWEEN DATE '2026-01-01' AND DATE '2026-01-31' GROUP BY sold_on, product_id; ``` If nothing sold on the 14th, `sales` has no row with that date, so `GROUP BY` forms no group, and the output simply skips the day. No filter, `COALESCE` or `HAVING` can recover it — the information is not missing from the aggregate, it is missing from the input. ## The spine pattern The answer is to generate the rows that ought to exist and attach the data to them. That generation is exactly a Cartesian product, and this is the canonical deliberate use of `CROSS JOIN`: ```sql SELECT d.day, p.product_id, COALESCE(SUM(s.amount), 0) AS revenue FROM calendar d CROSS JOIN products p LEFT JOIN sales s ON s.sold_on = d.day AND s.product_id = p.product_id WHERE d.day BETWEEN DATE '2026-01-01' AND DATE '2026-01-31' GROUP BY d.day, p.product_id ORDER BY d.day, p.product_id; ``` Three steps, each doing one job: 1. **`calendar CROSS JOIN products`** builds the spine — every `(day, product)` pair that should appear, whether or not it has data. For 31 days and 12 products that is 372 rows. 2. **`LEFT JOIN sales`** attaches the facts. The spine is the preserved side, so a pair with no sale survives with NULL sales columns. 3. **`COALESCE(SUM(s.amount), 0)`** converts the empty aggregate into a reported zero. `SUM` over a group whose only rows are NULL-extended returns NULL, not 0, so the `COALESCE` is not optional if the report must print 0. ## The predicate-placement trap Both join keys belong in the `ON` clause. Writing `WHERE s.product_id = p.product_id` instead filters the joined result after NULL-extension, and every unmatched spine row is discarded — silently turning the outer join back into an inner one and undoing the whole pattern. Filters on the *spine* (`WHERE d.day BETWEEN …`) are safe in `WHERE`, because those columns are never NULL-extended; filters on the *sales* side are not. ## Where the date list comes from A persisted `calendar` (or `dim_date`) table is the portable option and is standard practice in warehouses, because it also carries useful attributes like `is_weekend`, fiscal period and quarter. Engines differ in what they offer instead: PostgreSQL has `generate_series`, and other engines have their own row-generating idioms or none at all — check your engine's documentation rather than assuming a set-returning function exists. Where a fixed small range is enough, a `VALUES` table constructor in the `FROM` clause works too. The same shape generalises past dates: cross a region list with a category list to get a complete matrix; cross an hour list with a server list for a monitoring heat map; cross plans with currencies to build test fixtures covering every combination. ## Sizing the grid Because this is a deliberate product, its size is the multiplication you signed up for. Two years of days (730) crossed with 50,000 SKUs is 36.5 million spine rows before a single sale is joined. Keep the spine narrow: filter the date range and the dimension *before* crossing them — for example, cross the dates only with products that were active in the period, using a derived table — rather than generating the universe and discarding it afterwards. ## Why not solve it in the application You can fetch the sparse aggregate and fill the gaps in application code, and for a single chart that is often simpler. The SQL spine wins when the zero-filled result feeds further SQL — a running total, a rank, another join — or when several consumers must agree on what "zero" means. Doing it once in the query keeps the definition in one place.

  • Why is COALESCE still needed if the LEFT JOIN already keeps the unmatched rows?
    Keeping the row and reporting a number are different things. For a spine row with no match, every sales column is NULL, and `SUM` over only NULLs returns NULL rather than 0. Without `COALESCE(SUM(s.amount), 0)` the report prints an empty cell where the business expects a zero.
  • What breaks if you move the product-matching condition from ON into the WHERE clause?
    The outer join collapses to an inner join. After NULL-extension the unmatched spine rows carry `s.product_id IS NULL`, so a `WHERE s.product_id = p.product_id` comparison is never true and those rows are filtered away — exactly the rows the spine existed to preserve. Only spine-side filters are safe in `WHERE`.
  • How do you keep the spine from becoming enormous over a long date range?
    Restrict both factors before crossing them: bound the date range in a derived table or the calendar filter, and cross only the dimension members that are relevant — active products, or those with any sale in the window. The grid is a deliberate product, so every row you avoid generating is multiplied savings.

It is the difference between a stack of receipts and a blank attendance grid: you cannot see who was absent by reading receipts, so you print every name against every day first and then tick off the ones that happened.

saying these in an interview costs you the question

  • Believes GROUP BY invents rows for days with no data
  • Thinks SUM returns 0 for a group of NULL-extended rows
  • Puts the fact-table join key in WHERE instead of ON
  • Crosses the full calendar with every product regardless of range
  • Says the gaps should always be filled in application code

context