- データベース
PostgreSQL generate_series使い方|連番・日付生成
generate_seriesとは、開始値・終了値・刻み幅を渡すと、その範囲の値を1行ずつ返してくれるPostgreSQLの関数です。 整数の連番だけでなく、日付や時刻の連続データも作れるので、「テーブルを用意せずに1〜100の行がほしい」「カレンダーのように毎日1行ずつほしい」「テストデータを100万件入れたい」といった場面をSQL1本でこなせます。
この記事では、用途別のSQLをPostgreSQL 16.15で実際に実行した結果つきで並べました。売上の無い日を0で埋めるLEFT JOINの定番形、random()やmd5と組み合わせた100万件のテストデータ投入と所要時間の実測、PostgreSQL 16で加わったタイムゾーン引数、date型を渡すと戻り値がtimestamptzになる点まで、順に確認していきます。
generate_seriesの早見表
まずは「やりたいこと」から引ける一覧です。結果の列は、この記事の検証環境で実行して得た値です(複数行になるものはカンマ区切りで表記しています)。
| やりたいこと | SQL | 結果 |
|---|---|---|
| 1〜5の連番 | SELECT generate_series(1,5); |
1,2,3,4,5 |
| 5刻み | SELECT generate_series(0,20,5); |
0,5,10,15,20 |
| 逆順(カウントダウン) | SELECT generate_series(5,1,-1); |
5,4,3,2,1 |
| 小数の刻み | SELECT generate_series(0.0,1.0,0.25); |
0.0,0.25,0.50,0.75,1.00 |
| 日付を1日ずつ | SELECT d::date FROM generate_series('2026-10-01'::date,'2026-10-05'::date,'1 day') AS d; |
10/01〜10/05の5行 |
| 日付を整数の足し算で | SELECT '2026-10-01'::date + n FROM generate_series(0,2) AS n; |
10/01,10/02,10/03(date型) |
| 15分刻みの時刻 | SELECT generate_series('2026-10-01 09:00'::timestamp,'2026-10-01 10:00'::timestamp,'15 minutes'); |
09:00,09:15,09:30,09:45,10:00 |
| 月初を毎月 | SELECT generate_series('2026-01-01'::timestamp,'2026-04-01'::timestamp,'1 month'); |
1/1,2/1,3/1,4/1 |
| 毎週月曜 | SELECT generate_series('2026-10-05'::timestamp,'2026-11-02'::timestamp,'1 week'); |
10/05,10/12,10/19,10/26,11/02 |
| 直近3日 | SELECT d::date FROM generate_series(current_date - 2, current_date, '1 day') AS d; |
10/01,10/02,10/03(実行日10/03) |
| A〜Fの文字 | SELECT string_agg(chr(n),'') FROM generate_series(65,70) AS n; |
ABCDEF |
| ゼロ埋めコード | SELECT lpad(n::text,5,'0') FROM generate_series(1,3) AS n; |
00001,00002,00003 |
| 行番号つき | SELECT * FROM generate_series(1,3) WITH ORDINALITY AS t(val, idx); |
(1,1),(2,2),(3,3) |
| 100万行を数える | SELECT count(*) FROM generate_series(1,1000000); |
1000000 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL generate_series使い方|連番・日付生成」
検証環境と前提
この記事の実行結果は、すべて次の環境で取りました。
| 項目 | 内容 |
|---|---|
| PostgreSQL | 16.15(Ubuntu 24.04、x86_64) |
| クライアント | psql 16 |
| マシン | 2コア・メモリ約7GBの仮想環境 |
| 設定 | shared_buffers = 128MB(初期値のまま) |
| セッションのタイムゾーン | 特記が無いかぎり SET timezone='Asia/Tokyo'; |
所要時間は各1回の計測値です。マシンの性能や同時に動いている処理によって変わるので、桁の目安として読んでください。関数の引数と戻り値の型は、PostgreSQL 16の公式ドキュメント「集合を返す関数」のページを今回開いて確認しています。
基本の構文と5つの形
generate_seriesには、引数の型によって次の5つの形があります。
| 形 | 戻り値 | stepの省略 |
|---|---|---|
generate_series(start integer, stop integer [, step integer]) |
setof integer | 省略すると1 |
generate_series(start bigint, stop bigint [, step bigint]) |
setof bigint | 省略すると1 |
generate_series(start numeric, stop numeric [, step numeric]) |
setof numeric | 省略すると1 |
generate_series(start timestamp, stop timestamp, step interval) |
setof timestamp | 省略できない |
generate_series(start timestamptz, stop timestamptz, step interval [, timezone text]) |
setof timestamptz | 省略できない(timezoneは16から) |
押さえておきたい点は3つです。
- startとstopは両方とも結果に含まれます(stopをちょうど踏む場合)。
- 日付・時刻の形ではstep(interval)が必須です。
- date型専用の形はありません。date型を渡すと別の形に自動で読み替えられます(後述)。
整数の連番を作る
1から順に並べる
SELECT generate_series(1,5) AS n;
n
---
1
2
3
4
5
FROM句に書くこともできます。列に別名を付けて計算に使うときは、こちらの書き方が読みやすくなります。
SELECT n, n*n AS sq FROM generate_series(1,3) AS n;
n | sq
---+----
1 | 1
2 | 4
3 | 9
刻み幅を変える・逆順にする・小数にする
SELECT string_agg(n::text, ',') FROM generate_series(0,20,5) AS n;
SELECT string_agg(n::text, ',') FROM generate_series(5,1,-1) AS n;
SELECT string_agg(n::text, ',') FROM generate_series(0.0,1.0,0.25) AS n;
string_agg
--------------
0,5,10,15,20
string_agg
------------
5,4,3,2,1
string_agg
-------------------------
0.0,0.25,0.50,0.75,1.00
1行にまとめて見せるために string_agg で連結していますが、もとは1値1行です。
0行になる場合とエラーになる場合
startがstopより大きいのにstepを省略すると、エラーにはならず0行が返ります。 逆順にしたいときは、負のstepを必ず書きます。
SELECT count(*) FROM generate_series(5,1) AS n;
count
-------
0
stepに0を渡すとエラーになります。
SELECT generate_series(1,3,0);
ERROR: step size cannot equal zero
また、整数と小数を混ぜるとnumericの形が選ばれ、stopを超えない範囲までが返ります。
SELECT generate_series(1, 3.5);
generate_series
-----------------
1
2
3
連番から文字列を組み立てる
連番は、そのまま会員コードやメールアドレスのようなダミー値の材料になります。
SELECT n,
lpad(n::text, 5, '0') AS code,
'user' || n || '@example.com' AS email
FROM generate_series(1,3) AS n;
n | code | email
---+-------+-------------------
1 | 00001 | user1@example.com
2 | 00002 | user2@example.com
3 | 00003 | user3@example.com
日付の連番を作る
1日ずつ並べる定番形
SELECT d::date AS day
FROM generate_series('2026-10-01'::date, '2026-10-05'::date, '1 day') AS d;
day
------------
2026-10-01
2026-10-02
2026-10-03
2026-10-04
2026-10-05
::date を付けているのには理由があります。次の項で確認します。
date型を渡すとtimestamptzが返る
date型を渡したときの戻り値はdateではなく、timestamp with time zone(timestamptz)です。 date専用の形が無いため、dateがtimestamptzに読み替えられて5番目の形が選ばれます。
SELECT pg_typeof(d)
FROM generate_series('2026-10-01'::date, '2026-10-02'::date, '1 day') AS d LIMIT 1;
SELECT d
FROM generate_series('2026-10-01'::date, '2026-10-03'::date, '1 day') AS d;
pg_typeof
--------------------------
timestamp with time zone
d
------------------------
2026-10-01 00:00:00+09
2026-10-02 00:00:00+09
2026-10-03 00:00:00+09
時刻とタイムゾーンのオフセット(+09)が付いています。この値はセッションのタイムゾーン設定で見え方が変わります。同じSQLを SET timezone='UTC'; で実行すると、次のようになりました。
t
------------------------
2026-10-01 00:00:00+00
2026-10-02 00:00:00+00
日付だけがほしいときは、次のどちらかにすると型がはっきりします。
| 書き方 | 戻り値の型 | 向いている場面 |
|---|---|---|
generate_series(...)::date で受ける(d::date) |
date | とにかく日付の列がほしい |
引数を ::timestamp にする |
timestamp(タイムゾーンなし) | セッション設定の影響を受けたくない |
'2026-10-01'::date + n と整数連番を足す |
date | 最初から最後までdate型で扱いたい |
引数をtimestampにした場合の型と値は次のとおりです。
SELECT pg_typeof(d), d
FROM generate_series('2026-10-01'::timestamp, '2026-10-02'::timestamp, '1 day') AS d;
pg_typeof | d
-----------------------------+---------------------
timestamp without time zone | 2026-10-01 00:00:00
timestamp without time zone | 2026-10-02 00:00:00
整数連番を足してdate型のまま作る
dateに整数を足すと「その日数後のdate」になります。この性質を使うと、型がずっとdateのままです。
SELECT ('2026-10-01'::date + n) AS day,
pg_typeof('2026-10-01'::date + n)
FROM generate_series(0,2) AS n;
day | pg_typeof
------------+-----------
2026-10-01 | date
2026-10-02 | date
2026-10-03 | date
stepを省略するとエラーになる
日付の形ではstepが必須です。省略したり整数を渡したりすると、次のエラーになります。
SELECT generate_series('2026-10-01'::date, '2026-10-05'::date);
ERROR: function generate_series(date, date) does not exist
LINE 1: SELECT generate_series('2026-10-01'::date,'2026-10-05'::date...
^
HINT: No function matches the given name and argument types. You might need to add explicit type casts.
型を書かずに文字列だけを渡した場合は、どの形か決められないというエラーになります。
SELECT generate_series('2026-10-01', '2026-10-03', '1 day');
ERROR: function generate_series(unknown, unknown, unknown) is not unique
LINE 1: SELECT generate_series('2026-10-01','2026-10-03','1 day');
^
HINT: Could not choose a best candidate function. You might need to add explicit type casts.
最初の引数に ::timestamp か ::date を付ければ解決します。 1つ目だけ型を付けた generate_series('2026-10-01'::timestamp, '2026-10-03', '1 day') は問題なく動きました。
今日を基準にした範囲
SELECT d::date
FROM generate_series(current_date - 2, current_date, '1 day') AS d;
d
------------
2026-10-01
2026-10-02
2026-10-03
ある月の1日から末日までを作るときは、date_truncで月初を求めてから「1か月後の前日」までを指定します。2026年2月で試すと28行になりました。
SELECT count(*)
FROM generate_series(
date_trunc('month', timestamp '2026-02-10'),
date_trunc('month', timestamp '2026-02-10') + interval '1 month - 1 day',
'1 day') AS d;
count
-------
28
時刻・月・曜日の連続データを作る
15分刻みのtimestamp
予約枠や時間帯別の集計に使える形です。
SELECT t
FROM generate_series('2026-10-01 09:00'::timestamp,
'2026-10-01 10:00'::timestamp,
'15 minutes') AS t;
t
---------------------
2026-10-01 09:00:00
2026-10-01 09:15:00
2026-10-01 09:30:00
2026-10-01 09:45:00
2026-10-01 10:00:00
stepのintervalには '30 seconds'、'6 hours'、'1 week' なども書けます。6時間刻みの例です。
SELECT string_agg(to_char(h,'HH24:MI'), ' ')
FROM generate_series('2026-10-01 00:00'::timestamp,
'2026-10-01 23:00'::timestamp, '6 hours') AS h;
string_agg
-------------------------
00:00 06:00 12:00 18:00
月初と月末の一覧
SELECT to_char(m,'YYYY-MM') AS month,
m::date AS first_day,
(m + interval '1 month' - interval '1 day')::date AS last_day
FROM generate_series('2026-01-01'::timestamp, '2026-04-01'::timestamp, '1 month') AS m;
month | first_day | last_day
---------+------------+------------
2026-01 | 2026-01-01 | 2026-01-31
2026-02 | 2026-02-01 | 2026-02-28
2026-03 | 2026-03-01 | 2026-03-31
2026-04 | 2026-04-01 | 2026-04-30
月末を起点に1か月ずつ進めると28日に寄る
月末の一覧を作ろうとして、1月31日から '1 month' で進めると、次の結果になりました。
SELECT string_agg(d::date::text, ',')
FROM generate_series('2026-01-31'::timestamp, '2026-05-31'::timestamp, '1 month') AS d;
string_agg
--------------------------------------------------------
2026-01-31,2026-02-28,2026-03-28,2026-04-28,2026-05-28
generate_seriesは「直前の値にstepを足す」ことを繰り返すため、2月で28日に丸まったあとは28日のまま進みます。月末を並べたいときは、次のどちらかにします。
-- 方法1:開始日に「nか月」を毎回足す
SELECT string_agg(('2026-01-31'::timestamp + n * interval '1 month')::date::text, ',')
FROM generate_series(0,4) AS n;
-- 方法2:月初を並べてから月末を計算する
SELECT string_agg((m + interval '1 month' - interval '1 day')::date::text, ',')
FROM generate_series('2026-01-01'::timestamp, '2026-05-01'::timestamp, '1 month') AS m;
string_agg
--------------------------------------------------------
2026-01-31,2026-02-28,2026-03-31,2026-04-30,2026-05-31
どちらも同じ結果になりました。
平日だけを取り出す
extractでISO曜日(月曜=1〜日曜=7)を取り、6未満に絞ります。
SELECT string_agg(to_char(d,'MM/DD(Dy)'), ' ')
FROM generate_series('2026-10-01'::timestamp, '2026-10-14'::timestamp, '1 day') AS d
WHERE extract(isodow FROM d) < 6;
10/01(Thu) 10/02(Fri) 10/05(Mon) 10/06(Tue) 10/07(Wed) 10/08(Thu) 10/09(Fri) 10/12(Mon) 10/13(Tue) 10/14(Wed)
祝日を除きたい場合は、祝日テーブルを別に用意して NOT EXISTS で外す形が扱いやすくなります。
SELECT句とFROM句、どちらに書くか
generate_seriesは「行の集合を返す関数」なので、テーブルと同じようにFROM句に書けます。SELECT句にも書けますが、2つ並べたときの動きに違いがあります。
-- FROM句で2つ:すべての組み合わせ(2×3=6行)
SELECT a, b FROM generate_series(1,2) AS a CROSS JOIN generate_series(1,3) AS b;
-- SELECT句で2つ:横に並べて、足りない側はNULL(3行)
SELECT generate_series(1,2) AS a, generate_series(1,3) AS b;
a | b
---+---
1 | 1
1 | 2
1 | 3
2 | 1
2 | 2
2 | 3
a | b
---+---
1 | 1
2 | 2
| 3
| 書く場所 | 2つ並べたとき | 向いている場面 |
|---|---|---|
| FROM句 | CROSS JOINで全組み合わせ | JOINやWHEREと組み合わせる、別名で参照する |
| SELECT句 | 行番号どうしで横に並び、短い側はNULL | 1つだけ手軽に使う |
迷ったらFROM句に書いて別名を付ける形に統一すると、JOINや集計と組み合わせやすくなります。組み合わせ(例:店舗×日付)がほしいときはCROSS JOINを使います。
WITH ORDINALITYで行番号を付ける
FROM句に書いた関数の後ろに WITH ORDINALITY を付けると、1から始まる行番号の列が追加されます。
SELECT * FROM generate_series(1,3) WITH ORDINALITY AS t(val, idx);
val | idx
-----+-----
1 | 1
2 | 2
3 | 3
売上の無い日を0で埋めるLEFT JOINの定番形
日別の集計をGROUP BYだけで行うと、データが無い日は行ごと抜けます。グラフや帳票では「0件の日」も1行ほしいので、generate_seriesで作った日付の一覧を左側に置き、実データをLEFT JOINするのが定番です。
サンプルデータ
CREATE TABLE sales (
id serial PRIMARY KEY,
sold_at timestamptz NOT NULL,
amount int NOT NULL
);
INSERT INTO sales (sold_at, amount) VALUES
('2026-10-01 10:00+09', 1200),
('2026-10-01 15:30+09', 800),
('2026-10-03 09:10+09', 500),
('2026-10-06 20:45+09', 3000),
('2026-10-06 23:50+09', 150);
直す前:GROUP BYだけだと日付が抜ける
SELECT sold_at::date AS day, sum(amount) AS total
FROM sales GROUP BY 1 ORDER BY 1;
day | total
------------+-------
2026-10-01 | 2000
2026-10-03 | 500
2026-10-06 | 3150
10/02、10/04、10/05、10/07がありません。
直した後:日付の一覧にLEFT JOINする
SELECT d::date AS day,
COALESCE(sum(s.amount), 0) AS total,
count(s.id) AS cnt
FROM generate_series('2026-10-01'::timestamp, '2026-10-07'::timestamp, '1 day') AS d
LEFT JOIN sales s
ON s.sold_at >= d::date
AND s.sold_at < d::date + 1
GROUP BY d
ORDER BY d;
day | total | cnt
------------+-------+-----
2026-10-01 | 2000 | 2
2026-10-02 | 0 | 0
2026-10-03 | 500 | 1
2026-10-04 | 0 | 0
2026-10-05 | 0 | 0
2026-10-06 | 3150 | 2
2026-10-07 | 0 | 0
ポイントは3つです。
| ポイント | 理由 |
|---|---|
COALESCE(sum(...), 0) |
相手がいない日のsumはNULLになるので0に置き換える |
count(s.id) と列を指定する |
count(*) だと相手がいない日も1と数えてしまう |
| 結合条件を「その日の0時以上、翌日0時未満」にする | sold_at側を加工しないので、sold_atのインデックスを使える形になる |
絞り込み条件はWHEREではなくONに書く
売上側の条件をWHERE句に書くと、せっかく埋めた日が消えます。実際に試した結果です。
SELECT d::date AS day, COALESCE(sum(s.amount),0) AS total
FROM generate_series('2026-10-01'::timestamp, '2026-10-07'::timestamp, '1 day') AS d
LEFT JOIN sales s ON s.sold_at::date = d::date
WHERE s.amount > 0 -- ここで相手がいない行が落ちる
GROUP BY d ORDER BY d;
day | total
------------+-------
2026-10-01 | 2000
2026-10-03 | 500
2026-10-06 | 3150
相手がいない日は s.amount がNULLなので、NULL > 0 が真にならず行が落ちます。右側テーブルの条件は ON ... AND s.amount > 0 のようにON句へ書くと、0の日が残ります。
応用:抜けている番号を探す
同じ考え方で、連番の抜けも探せます。idが1,2,4,7,8のテーブルで試しました。
CREATE TABLE items (id int PRIMARY KEY);
INSERT INTO items VALUES (1),(2),(4),(7),(8);
SELECT n AS missing_id
FROM generate_series((SELECT min(id) FROM items),
(SELECT max(id) FROM items)) AS n
LEFT JOIN items i ON i.id = n
WHERE i.id IS NULL;
missing_id
------------
3
5
6
こちらは「相手がいない行だけほしい」ので、WHEREで IS NULL を指定するのが正解です。
random()やmd5と組み合わせてテストデータを作る
部品の一覧
generate_seriesで行を作り、各列を次の部品で埋めていきます。
| ほしい値 | 式 | 出てくる値の例 |
|---|---|---|
| 0〜99の整数 | floor(random()*100)::int |
74 |
| 1〜10000の整数 | 1 + floor(random()*10000)::int |
8937 |
| 小数2桁の数値 | round((random()*1000)::numeric, 2) |
362.47 |
| 決まった候補から1つ | (ARRAY['new','paid','shipped'])[1 + floor(random()*3)::int] |
paid |
| 連番から決まる固定の文字列 | md5(n::text) |
c4ca4238a0b923820dcc509a6f75849b |
| ランダムな8文字 | substr(md5(random()::text), 1, 8) |
febd3f94 |
| 期間内のランダムな日時 | '2026-01-01'::timestamp + random() * interval '270 days' |
2026-01-26 10:47:06.323276 |
| 正規分布の値(16以降) | random_normal(50, 10) |
43.92113600817729 |
random()は0以上1未満の値を返します。md5(n::text) は同じnからは毎回同じ文字列になるので、何度作り直しても同じ値にしたい列に向いています。実行するたびに変えたい列は md5(random()::text) を使います。
3行だけ作って確認した結果です(SELECT setseed(0.42); を先に実行して乱数の種を固定しています)。
SELECT setseed(0.42);
SELECT n,
floor(random()*100)::int AS r100,
round((random()*1000)::numeric, 2) AS price,
substr(md5(random()::text), 1, 8) AS code,
(ARRAY['new','paid','shipped'])[1 + floor(random()*3)::int] AS status,
'2026-01-01'::timestamp + random() * interval '270 days' AS ts
FROM generate_series(1,3) AS n;
n | r100 | price | code | status | ts
---+------+--------+----------+---------+----------------------------
1 | 74 | 362.47 | febd3f94 | new | 2026-01-26 10:47:06.323276
2 | 21 | 490.88 | 24fd9258 | paid | 2026-07-23 13:38:16.447791
3 | 56 | 969.45 | 052aff3d | shipped | 2026-02-05 07:44:03.043269
100万件を入れるコピペSQL
注文テーブルを想定した完成形です。件数は最後の generate_series(1, 1000000) の数字を変えるだけです。
CREATE TABLE orders_test (
id bigint PRIMARY KEY,
member_id int NOT NULL,
status text NOT NULL,
amount int NOT NULL,
code text NOT NULL,
ordered_at timestamp NOT NULL
);
INSERT INTO orders_test
SELECT n,
1 + floor(random()*10000)::int,
(ARRAY['new','paid','shipped','done'])[1 + floor(random()*4)::int],
100 + floor(random()*9900)::int,
substr(md5(n::text), 1, 12),
'2026-01-01'::timestamp + random() * interval '270 days'
FROM generate_series(1, 1000000) AS n;
psqlで \timing on にして実行した結果です。
INSERT 0 1000000
Time: 3362.882 ms (00:03.363)
入ったデータを確認します。
SELECT count(*), min(amount), max(amount),
count(DISTINCT member_id) AS members,
pg_size_pretty(pg_total_relation_size('orders_test')) AS size
FROM orders_test;
SELECT status, count(*) FROM orders_test GROUP BY 1 ORDER BY 1;
SELECT * FROM orders_test ORDER BY id LIMIT 3;
count | min | max | members | size
---------+-----+------+---------+-------
1000000 | 100 | 9999 | 10000 | 95 MB
status | count
---------+--------
done | 250486
new | 249660
paid | 249588
shipped | 250266
id | member_id | status | amount | code | ordered_at
----+-----------+--------+--------+--------------+----------------------------
1 | 8937 | new | 2497 | c4ca4238a0b9 | 2026-04-18 23:57:25.043827
2 | 9637 | new | 7490 | c81e728d9d4c | 2026-03-04 11:02:27.69069
3 | 4307 | done | 895 | eccbc87e4b5c | 2026-09-25 15:53:24.390025
statusは4種類がほぼ均等(約25万件ずつ)に入り、主キーとインデックスを含めたサイズは95MBでした。
件数別の所要時間(実測)
同じINSERT文で件数だけを変えた結果です。
| 件数 | 条件 | 所要時間 |
|---|---|---|
| 1万件 | 主キーあり | 57 ms |
| 10万件 | 主キーあり | 578 ms |
| 100万件 | 主キーあり | 3,363 ms(約3.4秒) |
| 100万件 | 主キーなしで投入 | 4,054 ms |
| 上記のあと | ALTER TABLE ... ADD PRIMARY KEY (id) |
440 ms |
| 100万件 | UNLOGGEDテーブル・主キーなし | 2,117 ms(約2.1秒) |
| 100万件のあと | CREATE INDEX ON orders_test (ordered_at) |
744 ms |
| 100万件のあと | ANALYZE orders_test |
144 ms |
| 1,000万行 | SELECT count(*) FROM generate_series(1,10000000) |
1,241 ms |
読み取れることを整理します。
- 100万件は数秒で入りました。 1万件から100万件まで、件数にほぼ比例して時間が伸びています。
- 行を作るだけなら1,000万行でも約1.2秒でした。時間の大半は、random()やmd5の計算とテーブルへの書き込みにかかっています。
- 「主キーなしで投入してから後で主キーを付ける」形は、今回の環境では合計約4.5秒で、主キーありの3.4秒より短くなりませんでした。各1回の計測で、同じマシンで他の処理も動いていたため、この差は誤差の範囲と見ています。今回のように連番(昇順)の主キーだけなら、付けたままでも十分に速く入ります。
- UNLOGGEDテーブルは約2.1秒でした。UNLOGGEDはWAL(変更履歴のログ)を書かないテーブルで、その分だけ書き込みが軽くなります。サーバーが異常終了すると中身が空になる性質があるので、作り直せるテストデータ向きです。
大量に入れるときの手順
| 手順 | 内容 |
|---|---|
| 1. 小さく試す | まず generate_series(1, 10) でSELECTだけ実行し、値の形を確かめる |
| 2. 件数を増やす | 10万件で時間を測り、目標件数の所要時間を見積もる |
| 3. 投入する | 目標件数でINSERT。作り直せるデータならUNLOGGEDも選べる |
| 4. インデックスを作る | 主キー以外のインデックスは投入後に CREATE INDEX |
| 5. 統計を更新する | ANALYZE テーブル名; を実行して、実行計画が実データに合うようにする |
投入のあとにANALYZEを実行しておくと、検証したいSQLの実行計画が件数に見合ったものになります。
行数の見積もりについて
generate_seriesをFROM句に置いたとき、プランナーが見積もる行数は形によって違いました。
EXPLAIN SELECT * FROM generate_series(1,1000000);
EXPLAIN SELECT * FROM generate_series('2026-10-01'::timestamp,'2026-10-07'::timestamp,'1 day');
Function Scan on generate_series (cost=0.00..10000.00 rows=1000000 width=4)
Function Scan on generate_series (cost=0.00..10.00 rows=1000 width=8)
整数の形は引数から正確に100万行と見積もられますが、timestampの形は16.15では実際の7行に対して1000行と見積もられました。日付の一覧を大きなテーブルとJOINして計画が意図と違うときは、一覧をいったん一時テーブルに入れてANALYZEする方法があります。
PostgreSQL 16で加わったタイムゾーン引数
PostgreSQL 16から、timestamptzの形に4番目の引数 timezone が加わりました。公式ドキュメントでは、「1日」のようなstepを足すときの時刻と夏時間の調整を、指定したタイムゾーンで計算すると説明されています。省略時はセッションのTimeZone設定が使われます。
日本時間には夏時間が無いので、差が出るのは夏時間のある地域を扱うときです。ニューヨークで夏時間が終わる2026年11月1日をまたいで確かめました。セッションはUTCです。
SET timezone='UTC';
-- 4番目の引数あり
SELECT t, t AT TIME ZONE 'America/New_York' AS ny_local
FROM generate_series('2026-10-31 00:00-04'::timestamptz,
'2026-11-03 00:00-05'::timestamptz,
'1 day', 'America/New_York') AS t;
t | ny_local
------------------------+---------------------
2026-10-31 04:00:00+00 | 2026-10-31 00:00:00
2026-11-01 04:00:00+00 | 2026-11-01 00:00:00
2026-11-02 05:00:00+00 | 2026-11-02 00:00:00
2026-11-03 05:00:00+00 | 2026-11-03 00:00:00
ニューヨークの現地時刻で毎日0時ちょうどになっています。同じSQLから4番目の引数を外すと、UTC基準で24時間ずつ進むため、現地時刻が途中から23時にずれました。
t | ny_local
------------------------+---------------------
2026-10-31 04:00:00+00 | 2026-10-31 00:00:00
2026-11-01 04:00:00+00 | 2026-11-01 00:00:00
2026-11-02 04:00:00+00 | 2026-11-01 23:00:00
2026-11-03 04:00:00+00 | 2026-11-02 23:00:00
| 状況 | 書き方 |
|---|---|
| 日本時間だけを扱う | 4番目の引数は不要。timestamp型かdate型で作れば十分 |
| サーバーやセッションがUTCで、海外の現地日付ごとに1行ほしい | 4番目の引数に現地のタイムゾーン名を渡す |
| 15以前のバージョン | 4番目の引数は使えない。timestamp型で現地時刻の一覧を作ってから AT TIME ZONE で変換する |
15以前で動かした結果は今回取っていません(未実行)。同じ考え方の関数として、16では date_add(timestamptz, interval, timezone) も使えます。
SELECT date_add('2026-10-31 00:00-04'::timestamptz, '1 day', 'America/New_York');
date_add
------------------------
2026-11-01 13:00:00+09
(セッションがAsia/Tokyoのときの表示です。ニューヨーク時間では11月1日0時にあたります。)
つまずきやすい点と対処の一覧
| 症状 | 原因 | 対処 |
|---|---|---|
| 0行しか返らない | startがstopより大きいのにstepが正 | 負のstepを指定する |
step size cannot equal zero |
stepが0 | 0以外を渡す |
function generate_series(date, date) does not exist |
日付の形でstepを省略した | '1 day' などのintervalを渡す |
function generate_series(unknown, unknown, unknown) is not unique |
文字列だけ渡して型が決まらない | 最初の引数に ::timestamp や ::date を付ける |
日付のはずが 2026-10-01 00:00:00+09 と表示される |
date型を渡すとtimestamptzが返る | d::date でキャストするか、整数連番を足す |
| 月末の一覧が28日に寄る | 直前の値にstepを足し続けるため | 月初から計算するか、開始日にnか月を足す |
| LEFT JOINで埋めたはずの日が消える | 右側テーブルの条件をWHEREに書いた | 条件をON句に移す |
| 0件の日の件数が1になる | count(*) で数えている |
count(s.id) のように右側の列を数える |
| SELECT句に2つ並べたらNULLが出る | 行番号どうしで横に並ぶ仕様 | 全組み合わせがほしいならFROM句でCROSS JOIN |
よくある質問
Q. generate_seriesの終了値は結果に含まれますか?
A. 含まれます。generate_series(1,5) は1から5までの5行です。ただし刻み幅の都合でちょうど終了値に届かない場合は、終了値を超えない最後の値までが返ります(generate_series(0,10,3) なら0,3,6,9)。この例は一般的な仕様の説明で、記事内の実行結果では generate_series(1, 3.5) が1,2,3になることを確認しています。
Q. 日付の連番を作ったら時刻やタイムゾーンが付いてきました。どうすればよいですか?
A. date型を渡すと戻り値がtimestamptzになるためです。d::date でキャストするか、'2026-10-01'::date + n のように整数の連番を足す形にすると、date型のまま扱えます。
Q. 100万件のテストデータを入れるのにどのくらいかかりますか? A. この記事の環境(2コア、PostgreSQL 16.15)では、6列・主キーありのテーブルに約3.4秒、UNLOGGEDテーブルに約2.1秒でした。列数やインデックスの数、マシン性能で変わるので、まず10万件で測ってから見積もるのがおすすめです。
Q. MySQLにもgenerate_seriesはありますか?
A. 同じ名前の関数はPostgreSQLのものです。MySQL 8.0では、再帰CTE(WITH RECURSIVE)で連番を作る方法が使えます。MySQL 8.0.46で WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n<1000) SELECT n FROM s が1000行を返すことを確認しています。
まとめ
- generate_series(開始, 終了, 刻み)で、整数・小数・日時の連続データを1行ずつ作れる
- 日付や時刻の形ではstep(interval)が必須。省略すると関数が見つからないエラーになる
- date型を渡すと戻り値はtimestamptz。日付だけほしいときは
::dateか、整数連番の足し算を使う - 0の日を埋めるには、日付一覧を左に置いてLEFT JOINし、
COALESCE(sum(...),0)とcount(右側の列)を使う。右側の条件はON句に書く - テストデータはrandom()・md5・配列の添字を組み合わせる。100万件は今回の環境で約3.4秒、UNLOGGEDなら約2.1秒
- PostgreSQL 16のタイムゾーン引数は、夏時間のある地域の「現地の毎日0時」を正しく並べたいときに使う
- 月末を1か月ずつ進めると28日に寄るので、月初から計算する形にする
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL generate_series使い方|連番・日付生成」
株式会社RJCでは、PostgreSQLの関数やSQLの書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。