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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?