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 #
-- 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 #
Common patterns #
-- 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 #
| Engine | Syntax | Gotcha |
|---|---|---|
| PostgreSQL | generate_series(1, 10) | table function; call it in FROM |
| MySQL | WITH RECURSIVE CTE | 8.0+ only |
| SQL Server | recursive CTE or master..spt_values tally | MAXRECURSION defaults to 100; raise with OPTION (MAXRECURSION 0) |
| BigQuery | UNNEST(GENERATE_ARRAY(1, 10)) | returns an array; UNNEST to get rows |
| Snowflake | SEQ4() over TABLE(GENERATOR(ROWCOUNT => 10)) | SEQ4() can have gaps — use ROW_NUMBER() when gap-free matters |
| Oracle | SELECT LEVEL FROM dual CONNECT BY LEVEL <= 10 | — |
| SQLite | WITH RECURSIVE CTE | — |
| DuckDB | generate_series(1, 10) | inclusive stop; range(1, 11) is exclusive |
Dialect notes #
- Postgres / DuckDB:
generate_series(start, stop[, step])returns rows. Also acceptsTIMESTAMP/DATEarguments, so it doubles as a single function for date series. - BigQuery:
GENERATE_ARRAY(start, stop[, step])returns an array;UNNESTto get rows. AlsoGENERATE_DATE_ARRAY,GENERATE_TIMESTAMP_ARRAY. - Snowflake:
TABLE(GENERATOR(ROWCOUNT => N))plusSEQ4()to get sequential numbers. Less ergonomic thangenerate_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_valuessystem 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.
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.