Recipe

Generate a sequence of numbers

Short answer: generate_series(start, stop) on Postgres / DuckDB returns one row per integer in the range. GENERATE_ARRAY(start, stop) on BigQuery returns an array; pair with UNNEST to get rows. Snowflake / Redshift / Oracle use a recursive CTE or row-generating tricks. Use this whenever you need a number spine: for date series, gap detection, padding missing rows, or generating sample data.

Canonical SQL #

sql
-- Postgres / DuckDB.
SELECT * FROM generate_series(1, 10);                  -- 1, 2, 3, ..., 10
SELECT * FROM generate_series(1, 100, 5);              -- 1, 6, 11, ... (step = 5)

-- BigQuery.
SELECT * FROM UNNEST(GENERATE_ARRAY(1, 10)) AS n;
SELECT * FROM UNNEST(GENERATE_ARRAY(1, 100, 5)) AS n;

-- Snowflake.
SELECT seq4() AS n FROM TABLE(GENERATOR(ROWCOUNT => 10));

-- Recursive CTE — works on every engine that supports recursive CTEs.
WITH RECURSIVE numbers(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT * FROM numbers;

-- MySQL 8+ recursive form.
WITH RECURSIVE numbers AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT * FROM numbers;

Example #

Loading SQL editor...

Common patterns #

sql
-- Pad a chart with zero-rows for missing buckets.
WITH spine AS (SELECT n AS bucket FROM generate_series(0, 9) AS g(n)),
bucketed_data(bucket, value) AS (
  VALUES (1, 14), (3, 8), (7, 22)
)
SELECT
  s.bucket,
  COALESCE(actual.value, 0) AS value
FROM   spine s
LEFT JOIN (SELECT bucket, value FROM bucketed_data) actual USING (bucket);

-- Generate test data: 1000 fake rows.
SELECT
  n                                          AS id,
  'user_' || n                               AS username,
  RANDOM() * 1000                            AS spend
FROM   generate_series(1, 1000) AS t(n);

-- Tally table for "appears N times" expansion.
WITH templates(id, body, copies) AS (
  VALUES (1, 'reminder', 2), (2, 'invoice', 3)
)
SELECT t.*, n AS copy_num
FROM   templates t,
       generate_series(1, t.copies) AS s(n);

-- Numbers as a basis for date math (combine with INTERVAL).
SELECT DATE '2026-01-01' + (n || ' days')::INTERVAL AS day
FROM   generate_series(0, 364) AS t(n);
-- Every day in 2026.

Dialect comparison #

EngineSyntaxGotcha
PostgreSQLgenerate_series(1, 10)table function; call it in FROM
MySQLWITH RECURSIVE CTE8.0+ only
SQL Serverrecursive CTE or master..spt_values tallyMAXRECURSION defaults to 100; raise with OPTION (MAXRECURSION 0)
BigQueryUNNEST(GENERATE_ARRAY(1, 10))returns an array; UNNEST to get rows
SnowflakeSEQ4() over TABLE(GENERATOR(ROWCOUNT => 10))SEQ4() can have gaps — use ROW_NUMBER() when gap-free matters
OracleSELECT LEVEL FROM dual CONNECT BY LEVEL <= 10
SQLiteWITH RECURSIVE CTE
DuckDBgenerate_series(1, 10)inclusive stop; range(1, 11) is exclusive

Dialect notes #

  • Postgres / DuckDB: generate_series(start, stop[, step]) returns rows. Also accepts TIMESTAMP / DATE arguments, so it doubles as a single function for date series.
  • BigQuery: GENERATE_ARRAY(start, stop[, step]) returns an array; UNNEST to get rows. Also GENERATE_DATE_ARRAY, GENERATE_TIMESTAMP_ARRAY.
  • Snowflake: TABLE(GENERATOR(ROWCOUNT => N)) plus SEQ4() to get sequential numbers. Less ergonomic than generate_series.
  • MySQL (8.0+): only via recursive CTE.
  • SQL Server: recursive CTE, or use a "tally table" (a permanent table of integers up to some max). The master..spt_values system table has 0..2047 already.
  • Oracle: SELECT LEVEL FROM dual CONNECT BY LEVEL <= N — Oracle hierarchical query.
  • SQLite: recursive CTE only.

generate_series (and equivalents) are table functions: they appear in the FROM clause and produce rows. Don't try to call them like scalar functions.

Tip

Number spines are the foundation of every "missing rows" and "empty bucket" report. Without a spine, your chart shows only the buckets where data exists: the dashboard renders 7 hours and you don't notice that hours 8-12 had no events. Always spine + LEFT JOIN for time-series and bucketed reports. The companion: Generate dates between two dates.