The problem: GROUP BY can't show what isn't there

the-problem.sql
SELECT order_date, COUNT(*) AS order_count
FROM orders
WHERE order_date BETWEEN '2026-01-01' AND '2026-01-10'
GROUP BY order_date;

-- If Jan 4th had zero orders, it simply never appears as a row —
-- there's nothing wrong with the query, there's nothing to group

A line chart built directly from this result will connect January 3rd straight to January 5th, making a zero-order day invisible instead of showing a dip to zero — which can hide a real outage or, less dramatically, just produce a misleading chart.

Advertisement

Building a date spine in PostgreSQL

date-spine-postgres.sql
SELECT GENERATE_SERIES('2026-01-01'::date, '2026-01-10'::date, '1 day'::interval)::date AS calendar_date;

-- LEFT JOIN real data onto it — every date appears, with 0 where there's no match
SELECT c.calendar_date, COUNT(o.order_id) AS order_count
FROM GENERATE_SERIES('2026-01-01'::date, '2026-01-10'::date, '1 day'::interval)::date AS c(calendar_date)
LEFT JOIN orders o ON o.order_date = c.calendar_date
GROUP BY c.calendar_date
ORDER BY c.calendar_date;

Building a date spine in SQL Server

date-spine-mssql.sql
WITH calendar AS (
  SELECT CAST('2026-01-01' AS DATE) AS calendar_date
  UNION ALL
  SELECT DATEADD(DAY, 1, calendar_date)
  FROM calendar
  WHERE calendar_date < '2026-01-10'
)
SELECT c.calendar_date, COUNT(o.order_id) AS order_count
FROM calendar c
LEFT JOIN orders o ON o.order_date = c.calendar_date
GROUP BY c.calendar_date
OPTION (MAXRECURSION 366);

The MAXRECURSION hint matters here — SQL Server's default recursion limit is 100, which silently truncates a date range longer than 100 days unless it's explicitly raised.

Advertisement

Finding the actual missing dates

To list only the gaps, rather than a full report, filter for where the join found nothing:

list-only-gaps.sql
SELECT c.calendar_date AS missing_date
FROM GENERATE_SERIES('2026-01-01'::date, '2026-01-10'::date, '1 day'::interval)::date AS c(calendar_date)
LEFT JOIN orders o ON o.order_date = c.calendar_date
WHERE o.order_id IS NULL;

This is the same core pattern used to find gaps in an invoice number sequence or any other supposedly-continuous series — a calendar table is just a date-typed version of the same idea covered in Identify Gaps and Islands in SQL.

When to use a permanent calendar table

For reports run frequently or across a long, fixed range, a small permanent table — one row per day, spanning a few decades — is often better than regenerating a series on every query. It also has room for extra columns a generated series doesn't have on its own:

permanent-calendar-table.sql
CREATE TABLE calendar_dates (
  calendar_date  DATE PRIMARY KEY,
  day_of_week    VARCHAR(10),
  is_weekend     BOOLEAN,
  fiscal_quarter INT
);
-- Populated once (often via GENERATE_SERIES or a recursive CTE), then reused by every report

Common mistakes

  • Trusting a GROUP BY result to represent every date in a range — it only ever shows dates that actually have rows.
  • Forgetting SQL Server's default 100-level recursion limit when building a date spine longer than about three months with a recursive CTE.
  • Comparing a DATETIME column to a DATE-typed calendar without truncating the time portion first, causing every join to silently fail to match.
  • Regenerating a multi-year date series on every single report run instead of using a small permanent calendar table for frequently-run reports.

Key takeaways

  • GROUP BY only returns dates that exist in the data — a zero-activity day produces no row at all, not a row with zero.
  • GENERATE_SERIES() builds a date spine directly in PostgreSQL; SQL Server needs a recursive CTE or a stored calendar table.
  • LEFT JOIN the calendar onto real data, then WHERE the joined key IS NULL to list only the gaps.
  • Watch SQL Server's default MAXRECURSION limit of 100 when generating longer date ranges.
  • A permanent calendar table is worth it for frequently-run reports and can carry extra columns like fiscal period or holiday flags.