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 #
-- 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 #
Common patterns #
-- 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 #
| Engine | Syntax | Gotcha |
|---|---|---|
| PostgreSQL | generate_series(start, stop, INTERVAL '1 day') | returns timestamps — cast ::DATE |
| MySQL | WITH RECURSIVE + DATE_ADD(day, INTERVAL 1 DAY) | 8.0+ only |
| SQL Server | recursive CTE + DATEADD(day, 1, d) | MAXRECURSION defaults to 100 rows, so raise it for long spines |
| BigQuery | UNNEST(GENERATE_DATE_ARRAY(start, stop)) | also GENERATE_TIMESTAMP_ARRAY |
| Snowflake | DATEADD(day, SEQ4(), start) + GENERATOR(ROWCOUNT => n) | no native date-series function — row count computed up front |
| Oracle | SELECT start_date + LEVEL - 1 FROM dual CONNECT BY LEVEL <= n | — |
| SQLite | recursive CTE + date(day, '+1 day') | dates are strings |
| DuckDB | generate_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). AlsoGENERATE_TIMESTAMP_ARRAY. - Snowflake: no native date-series function. Use
DATEADD(day, seq4(), start)withGENERATOR. - 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).
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.