Contents

Data, SQL and Getting to the Answer

Lesson 22 of 66. Sign in to keep your place.

Module 2 · Putting tables together

Generating the rows that are not there

8 min read

The month with no sales, and how to make it appearGROUP BY branch, monthKochi sold nothing in March, so there is nopile and no rowThe chart draws a straight line from Februaryto AprilMonth-on-month growth compares March withJanuary without saying soThe regional manager never sees the row worthasking aboutSpine LEFT JOIN factsgenerate_series produces six months whether ornot anything happenedCROSS JOIN branches gives every branch-monthcombinationThe date test sits in ON, not WHERE, or thespine rows are discarded againcount(o.id), not count(*), so an empty monthreports zero and not oneA grouped query can only report on rows that exist, so a branch with no sales in March produces nopile and therefore no row. Missing rows are much harder to notice than wrong numbers, because nothingis there to look wrong.
A grouped query can only report on rows that exist, so a branch with no sales in March produces no pile and therefore no row. Missing rows are much harder to notice than wrong numbers, because nothing is there to look wrong.

Figure

The zero that never appears

A grouped query can only report on rows that exist. In March, the Kochi branch made no sales. GROUP BY branch, month therefore produces no pile for Kochi in March, and no row.

The consequences are worse than a missing line in a table. A chart draws a straight line from February to April, implying steady trade through a month with none. A month-on-month growth calculation compares March to January without saying so. A report emailed to a regional manager lacks the row that would have prompted a question.

Missing rows are much harder to notice than wrong numbers, because nothing is there to look wrong.

Generate the frame first

The fix is to build the complete set of rows you want (the spine) and left join the data onto it. In Postgres:

SQL
SELECT generate_series(date '2026-01-01', date '2026-12-01', interval '1 month')::date AS month;

Twelve rows, whether or not anything happened in them. Now cross join it with the branches to get every combination:

SQL
WITH months AS (
  SELECT generate_series(date '2026-01-01', date '2026-06-01', interval '1 month')::date AS month
),
spine AS (
  SELECT b.id AS branch_id, b.name, m.month
  FROM branches b CROSS JOIN months m
)
SELECT
  s.name,
  s.month,
  coalesce(sum(o.amount), 0) AS revenue,
  count(o.id)                AS orders
FROM spine s
LEFT JOIN orders o
       ON o.branch_id = s.branch_id
      AND o.placed_at >= s.month
      AND o.placed_at <  s.month + interval '1 month'
GROUP BY s.name, s.month
ORDER BY s.name, s.month;

Read the three parts that make it work.

The CROSS JOIN is deliberate: this is the one place a Cartesian product is exactly what you want. Six months times four branches is twenty-four rows, guaranteed.

The date condition sits in the ON clause, not in WHERE. This is the LEFT JOIN trap from the joins lesson: a WHERE o.placed_at >= ... would discard the spine rows that matched nothing, undoing the entire exercise.

coalesce(sum(o.amount), 0) turns the NULL from an empty pile into a zero. And note count(o.id), not count(*): the unmatched spine row still produces one output row, so count(*) would report one order in a month with none.

Portable ways to make a spine

generate_series is Postgres and DuckDB. Elsewhere:

A recursive CTE works in SQLite, MySQL 8 and SQL Server, each with its own date arithmetic. In SQLite:

SQL
WITH RECURSIVE months(m) AS (
  SELECT date('2026-01-01')
  UNION ALL
  SELECT date(m, '+1 month') FROM months WHERE m < '2026-06-01'
)
SELECT * FROM months;

A permanent calendar table is the approach most data warehouses take, and it is better than either. One row per date from 2015 to 2035, with columns for the month, quarter, fiscal year, weekday, and whether it is a public holiday in each market you operate in. Ten minutes to build, and every date question afterwards becomes a join instead of an argument about fiscal quarters.

SQL
CREATE TABLE calendar AS
SELECT d::date AS day,
       date_trunc('month', d)::date AS month_start,
       extract(isodow FROM d)       AS weekday,
       extract(isodow FROM d) >= 6  AS is_weekend
FROM generate_series(date '2015-01-01', date '2035-12-31', interval '1 day') d;

The holiday column cannot be computed and has to be loaded, which is exactly why having the table matters: it gives holidays somewhere to live, instead of being hard-coded into whichever query needed them last.

The same trick for categories

Spines are not only for dates. Any report that must show every category, including the empty ones, is the same shape: start from the dimension table, left join the facts.

SQL
SELECT c.title, count(e.student_id) AS students
FROM courses c
LEFT JOIN enrolments e ON e.course_id = c.id
GROUP BY c.title;

Courses with nobody enrolled appear with zero, which is very often the row somebody actually needs to see.

The judgement call

Not every gap should be filled with a zero. A missing month for a branch that opened in April is not zero revenue, it is not applicable, and printing zero implies a business that existed and sold nothing. Bound the spine by the entity’s own lifetime:

SQL
FROM branches b
CROSS JOIN months m
WHERE m.month >= date_trunc('month', b.opened_on)
  AND (b.closed_on IS NULL OR m.month <= date_trunc('month', b.closed_on))

The distinction between “zero happened” and “the question does not apply” is real, and it is the same distinction as zero versus NULL from the earlier lesson. A spine gives you the power to assert one of them; use it to assert the one that is true.

Every lesson here is open. Your progress is recorded once you pass the Building Apps with AI exam.

Check your understanding

A report LEFT JOINs a generated branch-by-month spine to orders and shows an order count of 1 in months where a branch had no orders at all, with revenue correctly shown as 0. What is wrong?

Pick the one you would defend. Your answer stays private.

Next lesson · 7 minGROUP BY: making piles and asking about each one

Stuck on something?Ask Divya, your AI tutor

How to cite: Addaly (2026) ‘Generating the rows that are not there’, in Data, SQL and Getting to the Answer. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/data-and-sql/a-spine-to-join-against (Accessed: 13 October 2026).