PostgreSQL のここ最近のメジャーリリース、PostgreSQL 18(2025年9月)、PostgreSQL 19(2026年秋リリース予定、6月にbeta1がリリース済)にて、立て続けにテンポラルテーブル関連の機能が追加されました。
テンポラルテーブルは、リレーショナルデータベース(RDB)の設計技法または設計方式のひとつです。各レコードにその内容はいつからいつまで有効であるという列を付加して、ある時点におけるデータに対する問い合わせを可能にします。基本的には、RDBの使い方なのですが、データベース製品側でもこのような使い方を補助する機能を用意していて、SQL標準にもなっています。
PostgreSQLはテンポラルテーブル関連機能の実装においては、立ち遅れていて、他のデータベース製品の後塵を拝していたわけですが、ここにきて、機能が追加されました。少し以前から PostgreSQL には「範囲データ型」が導入されていて、さらに、大昔から gistインデックスというものがサポートされていて、範囲に含まれるかということをインデックスで判定できます。これらの元からあった機能と相まって、今や、PostgreSQL こそがテンポラルテーブル設計に最も適したデータベース製品なのではないかと筆者は考えています。
以下、詳しく見ていきます。なお、そのうちにもっと詳しく論じようと思っていて、それがカンファレンスや勉強会の講演発表になるのか、勤務先の公式技術ブログになるのか、そのあたりは未定です。
テンポラルテーブルの世界
テンポラルテーブルの世界を体験してもらうために、例として、PostgreSQL付属のベンチマークツール pgbench の標準シナリオをテンポラルテーブル化してみます。
まずは以下のように実行して、新たなデータベース db1 に pgbench標準シナリオのテーブルとデータを生成させて、それを pg_dump で定義(db1.ddl)とデータ(db1.dat)に分けてダンプ出力します。
$ createdb db1
$ pgbench -i db1
$ pg_dump --schema-only db1 -f db1.ddl
$ pg_dump --data-only db1 -f db1.dat
次に db1.ddl を編集して、テーブル定義を変更します。
冒頭に以下を加えて、GiSTインデックスをinteger型にも利用可能にします。
CREATE EXTENSION IF NOT EXSITS btree_gist WITH SCHEMA public;
また、各テーブル定義を以下のように書き換えます。
CREATE TABLE public.pgbench_accounts (
aid integer NOT NULL,
bid integer,
abalance integer,
filler character(84),
valid_at tsrange DEFAULT
tsrange(CURRENT_TIMESTAMP::timestamp,NULL::timestamp)
)
WITH (fillfactor='100');
CREATE TABLE public.pgbench_branches (
bid integer NOT NULL,
bbalance integer,
filler character(88),
valid_at tsrange DEFAULT
tsrange(CURRENT_TIMESTAMP::timestamp,NULL::timestamp)
)
WITH (fillfactor='100');
CREATE TABLE public.pgbench_history (
tid integer,
bid integer,
aid integer,
delta integer,
mtime timestamp without time zone,
filler character(22),
valid_at tsrange DEFAULT
tsrange(CURRENT_TIMESTAMP::timestamp,NULL::timestamp)
);
CREATE TABLE public.pgbench_tellers (
tid integer NOT NULL,
bid integer,
tbalance integer,
filler character(84),
valid_at tsrange DEFAULT
tsrange(CURRENT_TIMESTAMP::timestamp,NULL::timestamp)
)
WITH (fillfactor='100');
ALTER TABLE ONLY public.pgbench_accounts
ADD CONSTRAINT pgbench_accounts_pkey PRIMARY KEY
(aid, valid_at WITHOUT OVERLAPS);
ALTER TABLE ONLY public.pgbench_branches
ADD CONSTRAINT pgbench_branches_pkey PRIMARY KEY
(bid, valid_at WITHOUT OVERLAPS);
ALTER TABLE ONLY public.pgbench_tellers
ADD CONSTRAINT pgbench_tellers_pkey PRIMARY KEY
(tid, valid_at WITHOUT OVERLAPS);
他に pgbench_history というテーブルもありますが、これは既に履歴的な性格のテーブルですので、今回はテンポラル化の対象外とすることにします。あらゆるテーブルを漏れなく対応させるべしという考え方もありますが、そこはデータベース設計上の判断となります。
書き換えられた点はどこでしょうか。各テーブルに valid_at という tsrange型(timestamp型の範囲型)の列が加わりました。デフォルト値は「現在時刻から終端無し」という範囲です。また、主キーの定義が、元々に主キーに valid_at WITHOUT OVERLAPS が加わったものに変わりました。元の主キー列が同じ値の行が valid_at の範囲が重ならない限り、いくらでも格納できるようになります。
なお、今回は timestamp型の範囲を使いましたが、timestamp with timezone型(timestamptz型)や date型の範囲型を使うこともできます。
データベースを作り直して、改定した定義とデータを投入します。このテーブル定義変更をした後でも、エラーなくデータを投入できます。
$ dropdb db1
$ createdb db1
$ psql -f db1.ddl db1
$ psql -f db1.dat db1
続いて、pgbench のトランザクションスクリプトも書き換えします。pgbench は --show-script オプションで標準のスクリプトを表示させることができます。
$ pgbench --show-script="tpcb-like"
-- tpcb-like: <builtin: TPC-B (sort of)>
\set aid random(1, 100000 * :scale)
\set bid random(1, 1 * :scale)
\set tid random(1, 10 * :scale)
\set delta random(-5000, 5000)
BEGIN;
UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :aid;
SELECT abalance FROM pgbench_accounts WHERE aid = :aid;
UPDATE pgbench_tellers SET tbalance = tbalance + :delta WHERE tid = :tid;
UPDATE pgbench_branches SET bbalance = bbalance + :delta WHERE bid = :bid;
INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VALUES (:tid, :bid, :aid, :delta, CURRENT_TIMESTAMP);
END;
$ pgbench --show-script="tpcb-like" &> tpcb-like.sql
$ vi tpcb-like.sql
これを次のように書き換えます。
\set delta random(-5000, 5000)
BEGIN;
UPDATE pgbench_accounts FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP::timestamp TO NULL SET abalance = abalance + :delta WHERE aid = :aid;
SELECT abalance FROM pgbench_accounts WHERE aid = :aid;
UPDATE pgbench_tellers FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP::timestamp TO NULL SET tbalance = tbalance + :delta WHERE tid = :tid;
UPDATE pgbench_branches FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP::timestamp TO NULL SET bbalance = bbalance + :delta WHERE bid = :bid;
INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VALUES (:tid, :bid, :aid, :delta, CURRENT_TIMESTAMP);
END;
変更点は UPDATE に FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP::timestamp TO NULL の句が加わったことです。SELECT と INSERT はそのままです。
これで pgbench を実行してみます。問題なく動作しました。
$ pgbench -f tpcb-like.sql -c 10 -t 100 db1
pgbench (19beta1)
starting vacuum...end.
transaction type: tpcb-like.sql
scaling factor: 1
query mode: simple
number of clients: 10
number of threads: 1
maximum number of tries: 1
number of transactions per client: 100
number of transactions actually processed: 1000/1000
number of failed transactions: 0 (0.000%)
latency average = 13.325 ms
initial connection time = 52.824 ms
tps = 750.451584 (without initial connection time)
$
さて、何が起きているでしょうか。以下のように SELECTを実行してデータをのぞいてみます。
db1=# SELECT * FROM pgbench_tellers WHERE tid = 1 LIMIT 10;
tid | bid | tbalance | filler | valid_at
-----+-----+----------+--------+-------------------------------------------------------------
1 | 1 | 0 | | ["2026-06-19 17:45:15.377261","2026-06-19 17:50:39.585863")
1 | 1 | -582 | | ["2026-06-19 17:50:39.585863","2026-06-19 17:50:39.596142")
1 | 1 | 1717 | | ["2026-06-19 17:50:39.596142","2026-06-19 17:50:39.59619")
1 | 1 | 1737 | | ["2026-06-19 17:50:39.59619","2026-06-19 17:50:39.62443")
1 | 1 | 1886 | | ["2026-06-19 17:50:39.62443","2026-06-19 17:50:39.648042")
1 | 1 | 6493 | | ["2026-06-19 17:50:39.648042","2026-06-19 17:50:39.650294")
1 | 1 | 6415 | | ["2026-06-19 17:50:39.650294","2026-06-19 17:50:39.653989")
1 | 1 | 5448 | | ["2026-06-19 17:50:39.653989","2026-06-19 17:50:39.68753")
1 | 1 | 9139 | | ["2026-06-19 17:50:39.720484","2026-06-19 17:50:39.739516")
1 | 1 | 16296 | | ["2026-06-19 17:50:40.207333","2026-06-19 17:50:40.213375")
(10 rows)
pgbench_tellers テーブルでは元々主キーであった tid が 1 である行がたくさん格納されています。値が変化するごとに、valid_at列に有効範囲が記録されています。
PORTION OFを加えた UPDATE は範囲を規定する列のデータが書き換えられて、行の追加が行われるわけです。
今回は、PORTION OF valid_at FROM 現在日付時刻 TO NULL という指定だけですが、「過去のある期間において値がこうであった」という意味で任意の FROM .. TO .. を指定することもできます。その場合も、既存行の valid_at列値の補正と行の挿入が行われます。
ここで以下のように valid_at列が示す範囲に現在の日付時刻が含まれるという意味の valid_at @> current_timestamp::timestamp という条件を WHERE句に加えて検索すると、これまで同様に、元の主キーである tid の値ごとに各1行という見慣れた結果になります。
db1=# SELECT * FROM pgbench_tellers WHERE valid_at @> current_timestamp::timestamp ORDER BY tid LIMIT 10;
tid | bid | tbalance | filler | valid_at
-----+-----+----------+--------+---------------------------------
1 | 1 | 19979 | | ["2026-06-19 17:50:40.910241",)
2 | 1 | 15335 | | ["2026-06-19 17:50:40.897119",)
3 | 1 | -4091 | | ["2026-06-19 17:50:40.89067",)
4 | 1 | -53302 | | ["2026-06-19 17:50:40.883171",)
5 | 1 | 31387 | | ["2026-06-19 17:50:40.882532",)
6 | 1 | -18138 | | ["2026-06-19 17:50:40.900224",)
7 | 1 | -13902 | | ["2026-06-19 17:50:40.913128",)
8 | 1 | -41984 | | ["2026-06-19 17:50:40.915585",)
9 | 1 | -23806 | | ["2026-06-19 17:50:40.901648",)
10 | 1 | -41768 | | ["2026-06-19 17:50:40.892869",)
(10 rows)
この条件を valid_at @> 《任意タイムスタンプ》 とすれば、ある時点のデータを参照することができます。これにより、「過去時点におけるデータの検索をいつでも実行できる」という要件を実現できるようになりました。もちろん、データ量は大幅に増えますし、実運用上はどこかで古いデータを順次捨てていくということが必要になります。
ここまでで、テンポラルテーブル設計のデータベースがどんなものか、また、PostgreSQL 19 での実現方式がどのようであるかは、ご理解いただけたかと思います。
PostgreSQL固有の強み:実行計画
ここからが PostgreSQL固有の強みの話です。
テンポラルテーブルの問い合わせの実行プランを確認してみましょう。今度は行数の多い(10万行 + トランザクション実行回数) pgbench_accounts テーブルを使ってみます。
db1=# explain (analyze)
SELECT * FROM pgbench_accounts WHERE aid = 100
AND valid_at @> current_timestamp::timestamp;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using pgbench_accounts_pkey on pgbench_accounts (cost=0.28..8.30 rows=1 width=111) (actual time=0.031..0.032 rows=1.00 loops=1)
Index Cond: ((aid = 100) AND (valid_at @> (CURRENT_TIMESTAMP)::timestamp without time zone))
Index Searches: 1
Buffers: shared hit=4
Planning Time: 0.101 ms
Execution Time: 0.046 ms
(6 rows)
db1=# explain (analyze)
SELECT * FROM pgbench_accounts WHERE aid < 100
AND valid_at @> current_timestamp::timestamp;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on pgbench_accounts (cost=5.32..333.06 rows=101 width=111) (actual time=0.077..0.091 rows=99.00 loops=1)
Recheck Cond: ((aid < 100) AND (valid_at @> (CURRENT_TIMESTAMP)::timestamp without time zone))
Heap Blocks: exact=4
Buffers: shared hit=7
-> Bitmap Index Scan on pgbench_accounts_pkey (cost=0.00..5.29 rows=101 width=0) (actual time=0.059..0.059 rows=99.00 loops=1)
Index Cond: ((aid < 100) AND (valid_at @> (CURRENT_TIMESTAMP)::timestamp without time zone))
Index Searches: 1
Buffers: shared hit=3
Planning Time: 0.156 ms
Execution Time: 0.130 ms
(10 rows)
一意な主キー指定があるものは Index Scan の実行計画になっていて、複数件が該当する問い合わせでは Bitmap Heap/Index Scan のプランになっています。GiST Index の Bitmap Heap/Index Scan は Index Scan ほどは速くないものの、低コストで値を絞り込める優秀な検索方法です。このとき使われているのは、同じ1つの GiSTインデックスです。
現時点でも、過去時点でも、テンポラルテーブル設計のデータベースへの問い合わせでの参照性能のペナルティは、それほど無いことを示しています。
執筆時点での主要リレーショナルデータベースソフトウェアの中で、範囲データ型に対する「範囲に含むか?」「範囲同士が重なるか」を検索できるインデックスを持つものは、GiSTインデックスを持つ PostgreSQL しかありません。矩形(BOX)等の図形の重なりについて R-tree インデックスを持つものはありますが、日付時刻の範囲に応用する自然な使い方は用意されていません。
さらに、整数型や文字列型などと範囲型を組み合わせて一つのインデックスとして、「WITHOUT OVERLAPS」の主キーインデックスを構成できるものも、PostgreSQL に限られます。
他のデータベース製品のテンポラルテーブル機能は、どうやって現実的な性能の範囲内で機能を実現しているかというと、例えば SQL Server では、最新データと過去データを分けて持つ方式を取っています。これであれば、最新データのテーブルについては肥大化しませんので、最新データを検索する分には通常のインデックス等があれば十分ということになります。過去データを調べるのにそれほど高速ではないのは、仕様として折り込み済ということであろうかと思われます。
PostgreSQLでテンポラルテーブル設計を実装することで、これまでと違った使いかた、いつでも沢山、過去時点問い合わせをすることが、現実的になります。これは何らか新しい可能性を開くのではないでしょうか。
もっと透過的に
ところで、今回 pgbench の標準シナリオをテンポラル化するにあたって、テーブル定義と問い合わせを書き換えました。テーブル定義を書き換えるのはともかく、問い合わせの書き換えは回避できないものでしょうか。できることなら、アプリケーション透過的に実現したいところです。
それは、実のところビューとルールを使用することで実現可能です。これは次回の記事で紹介します。
参考URL
参考ページとしては PostgreSQL 19 ドキュメントになります。