Recipe

Generate dates between two dates (date spine)

Short answer: generate_series(start_date, end_date, INTERVAL '1 day') on Postgres / DuckDB. GENERATE_DATE_ARRAY(start, end, INTERVAL 1 DAY) on BigQuery. Recursive CTE elsewhere. The "date spine" is the foundation of every time-series report. LEFT JOIN against your data and missing days appear as zeros instead of vanishing from the chart.

Canonical SQL #

sql
-- Postgres / DuckDB.
SELECT * FROM generate_series(DATE '2026-01-01', DATE '2026-01-31', INTERVAL '1 day');

-- BigQuery.
SELECT * FROM UNNEST(
  GENERATE_DATE_ARRAY(DATE '2026-01-01', DATE '2026-01-31', INTERVAL 1 DAY)
) AS day;

-- Snowflake.
SELECT DATEADD(day, seq4(), DATE '2026-01-01') AS day
FROM   TABLE(GENERATOR(ROWCOUNT => 31));

-- Recursive CTE — portable.
WITH RECURSIVE date_spine(day) AS (
  SELECT DATE '2026-01-01'
  UNION ALL
  SELECT day + INTERVAL '1 day' FROM date_spine WHERE day < DATE '2026-01-31'
)
SELECT * FROM date_spine;

-- MySQL 8+.
WITH RECURSIVE date_spine AS (
  SELECT DATE '2026-01-01' AS day
  UNION ALL
  SELECT DATE_ADD(day, INTERVAL 1 DAY) FROM date_spine WHERE day < DATE '2026-01-31'
)
SELECT * FROM date_spine;

Example #

Loading SQL editor...

Common patterns #

sql
-- Last 90 days, ending today.
SELECT day::DATE AS day
FROM   generate_series(
  CURRENT_DATE::TIMESTAMP - INTERVAL '89 days',
  CURRENT_DATE::TIMESTAMP,
  INTERVAL '1 day'
) AS t(day);

-- Weekly spine for the past year (one row per week start).
SELECT day::DATE AS day
FROM   generate_series(
  DATE_TRUNC('week', CURRENT_DATE::TIMESTAMP - INTERVAL '52 weeks'),
  CURRENT_DATE::TIMESTAMP,
  INTERVAL '1 week'
) AS t(day);

-- Every first-of-month for two years.
SELECT day::DATE AS day
FROM   generate_series(
  DATE '2025-01-01',
  DATE '2026-12-01',
  INTERVAL '1 month'
) AS t(day);

-- Cross-product spine (every day × every region) for a regional time-series.
SELECT s.day, r.region
FROM   generate_series(DATE '2026-01-01', DATE '2026-12-31', INTERVAL '1 day') AS s(day)
CROSS JOIN (VALUES ('EU'), ('US'), ('AP')) AS r(region);

-- Find days with no events.
SELECT s.day::DATE AS day
FROM   generate_series(DATE '2024-02-01', DATE '2024-02-29', INTERVAL '1 day') AS s(day)
LEFT JOIN ecom_events e ON e.event_timestamp::DATE = s.day::DATE
WHERE  e.event_id IS NULL;

Calendar table: when to materialize the spine #

For analytics warehouses, build a permanent calendar (or dim_date) table once: one row per date for the next 10-20 years, with derived columns (day-of-week, week-of-year, quarter, fiscal year, is_business_day, is_holiday). JOIN against it instead of generating spines per query. Benefits: consistent business-calendar logic, fast joins, clean BI tool integration. Downsides: needs maintenance for holidays and fiscal-year edge cases.

Most modern data stacks (dbt, Cube.dev, semantic layers) ship a calendar-table generator as a starter package. Use it.

Dialect comparison #

EngineSyntaxGotcha
PostgreSQLgenerate_series(start, stop, INTERVAL '1 day')returns timestamps — cast ::DATE
MySQLWITH RECURSIVE + DATE_ADD(day, INTERVAL 1 DAY)8.0+ only
SQL Serverrecursive CTE + DATEADD(day, 1, d)MAXRECURSION defaults to 100 rows, so raise it for long spines
BigQueryUNNEST(GENERATE_DATE_ARRAY(start, stop))also GENERATE_TIMESTAMP_ARRAY
SnowflakeDATEADD(day, SEQ4(), start) + GENERATOR(ROWCOUNT => n)no native date-series function — row count computed up front
OracleSELECT start_date + LEVEL - 1 FROM dual CONNECT BY LEVEL <= n
SQLiterecursive CTE + date(day, '+1 day')dates are strings
DuckDBgenerate_series(start, stop, INTERVAL '1 day')returns timestamps — cast ::DATE

Dialect notes #

  • Postgres / DuckDB: generate_series(start, stop, step) accepts dates and intervals directly.
  • BigQuery: GENERATE_DATE_ARRAY(start, stop, INTERVAL n unit). Also GENERATE_TIMESTAMP_ARRAY.
  • Snowflake: no native date-series function. Use DATEADD(day, seq4(), start) with GENERATOR.
  • MySQL / SQL Server / SQLite: recursive CTE with DATE_ADD / DATEADD / date(d, '+1 day').
  • Pre-built calendar tables: most warehouses have a community library or dbt package (dbt_date, dbt_utils.date_spine).
Tip

Date spines turn "the chart looks wrong on weekends" bugs into "the chart shows zero on weekends" features. Without a spine, days with no rows simply don't appear, so the line connects April 1 to April 5 as if April 2-4 had no time. With a spine LEFT JOINed in, those days appear as zeros, the line correctly drops to zero and rebounds, and a viewer immediately sees the gap. Always spine for time-series, especially when using a chart library that doesn't handle missing dates intelligently. Detail: Generate sequence of numbers.