PostgreSQL 18連載の3本目です。
PostgreSQL 18がリリースされ、仮想生成列についてまとめます。PostgreSQLで従来から利用できた格納生成列や、生成列自体と合わせて紹介します。
生成列
生成列は他の列から計算される列のことで、テーブルに対するビューをつくるように、ある列に対してビューのような列を作ることができます。ビューにも、MViewと通常のViewがあるように、生成列も「格納生成列」と「仮想生成列」の2種類があります。格納生成列は、登録/更新時に計算されて物理的にストレージが割り当てられます(MVIEWに似ています)。仮想列は列が読み取られる時に動的に計算されます(Viewに似ています)。
PostgreSQL 12で「格納」生成列が利用可能となり、今回18から「仮想」生成列が利用可能となりました。ここまで説明した内容をざっと、表でまとめました。
| 特徴 | 格納生成列 | 仮想生成列 |
|---|---|---|
| サポート | PostgreSQL 12以降 | PostgreSQL 18以降 |
| 説明 | 書き込み時に計算し、ストレージに保存される生成列 | 読み取り時に計算され、ストレージに保存されない生成列 |
| ストレージ容量 | ⚠️消費する | ✅️消費なし |
| 書き込み性能 | ⚠️やや遅くなる | ✅️影響なし |
| 読み取り性能 | ✅️計算済みのため | ⚠️都度計算するため |
生成列の全般に共通する使い方としては、「導出列」があります。導出列とは、他のカラムから算出できる列です。まさに生成列の用途にドンピシャ被りですね。一般論としては、導出列をもたせることは冗長性であるため、SSoT(信頼できる唯一の情報源)原則を守るため、作成しない方針を取るチームが多いでしょう。ただ、導出列にインデックスを貼りたいといった性能要件や、その他、設計の見える化や運用要件などで作成することがあります。
例をいくつか上げます。(凡例: 元の列 -> 導出列)
- 例1: 単価×数量x税係数x割引係数 -> 請求金額
- 例2: 姓 + 名 -> フルネーム
- 例3: 日付カラム -> 曜日
- 例4: 生年月日 -> 年齢
- 例5: メールアドレス -> 検索用メールアドレス(全て小文字にするなど加工し、検索用に正規化する)
どれもアプリケーション側で計算して明示的にインサートしても良いものですが、生成列を使用することでその列が読み取り専用であることを開発者に伝えることができ、また整合性を伴わない更新事故を無くすことができます。また、テーブル定義上で宣言的に意図を伝えられる点で、きっと生成AIとも親和性が良いと思います(これは未検証、想像で書いています)。
ただ、PostgreSQL設計ガイドライン では、ライフサイクルがアプリケーションに近く、格納生成列の定義変更はAccessExclusiveLock(SELECTもブロックされるロック)を取って全行更新の処理が必要となるため、この用途での格納生成列の使用は非推奨としていました。同様の部分の懸念はそれなりの規模感のシステムでは共通的であるため、あまり利用頻度は高くないと思います(※2025年10月29日時点では、仮想生成列についての記述はありません)。ただし、仮想生成列であれば、おそらく定義変更してもAccessExclusiveLockを取らないと思うので、障壁は下がるかもしれません。このあたりを検証していきます。
使用方法の基礎
検証環境情報やセットアップは村田さんのB-treeインデックスのスキップスキャン記事の手順に従います。Dockerで postgres:18 のイメージを利用します。
生成列の使い方ですが、ドキュメント にあるように、 GENERATED ALWAYS AS (ロジック) STORED で格納生成列、VIRTUAL を付けると仮想生成列になります。デフォルトは VIRTUAL です。身長[cm]をインチ版と尺版を生成列で作ってみます。
CREATE TABLE people ( |
実際にデータを登録 & 検索します。
postgres=# INSERT INTO people (height_cm) VALUES (170.5), (158.2), (181.0); |
読み取りすると、自動変換された値が取得できました。インチ・尺のどちらも実生活で意識して使ったことが無いでので合っているかよく分からないですが、自動計算されるのは便利です。また、INSERT文がシンプルになるという心理的な嬉しさを思ったより感じました。
検証サマリ
格納生成列、仮想生成列それぞれで以下の観点を比較します。
| 検証項目 | 結果 |
|---|---|
| 1. 生成列で自分自身の列を参照できるか | 不可(生成列は生成列を参照できないた) |
| 2. 他のテーブルの列を参照できるか | 不可(サブクエリの利用は不可) |
| 3. ネストした生成列定義は可能か | 不可(生成列は生成列を参照できないため) |
| 4. 計算途中でnull値が混入したらどうなるか | 普通のクエリと同じように null になる |
| 5. NOT NULL制約を付けることができるか | 可能。仮想生成列も登録時チェックになる |
| 6. 生成列は一意制約を付けることができるか | 格納生成列は可能。仮想生成列は不可。式インデックスで代用 |
| 7. 生成列はインデックスに使えるか | 格納生成列は可能。仮想生成列は式インデックスで代用 |
| 8. 生成列はPKにできるか | 格納生成列は可能。仮想生成列はインデックスを持てないので不可 |
| 9. 生成列はパーティションキーに使えるか | 不可(STORED / VIRTUAL 共に不可) |
| 10. テーブル作成後に後から生成列を追加できるか | 可能。AccessExclusiveLock を取る |
| 11. NOT NULL制約を付けた生成列を後から追加すると処理時間はどうなるか | NOT NULL検証のためのテーブルフルスキャンが発生し、変更時間が長くなる |
| 12. 生成列の定義変更はできるか | 不可(DROP COLUMN & ADD COLUMN で対応する必要がある) |
| 13. 利用しているカラムをRENAMEしたら? | PostgreSQLが自動で定義式を追随・更新してくれる |
| 14. 利用しているカラムをDROPしたら? | エラーになる。 CASCADE を付けると依存した列ごと削除可能) |
1. 生成列で自分自身の列を参照できるか
試しに、生成列で自分自身(self_value)を参照した定義を実行してみます。内容に意味は無いですが、 self_value = self_value + 1 となるような計算式を設定しています。
CREATE TABLE self_reference ( |
結果はNGです。「生成列は他の生成列を参照できない」とありますね。実現したいことも意味不明なので、失敗して当然なので想定通りかなと。この結果は、VIRTUAL を STORED に変えても同じです。
2. 他のテーブルの列を参照できるか
まず、税率を保持するテーブルを作成します。
CREATE TABLE m_tax ( |
続いて、さきほど作った税率テーブルを参照する、生成列を作ってみます。
CREATE TABLE t_order ( |
これはエラーになりました。サブクエリはNG(つまり、別テーブルの参照は不可)のようです。
ドキュメントにも References to other tables are not allowed. (他のテーブルは参照できない)と書いていますので、その通りの結果です。格納生成列、仮想生成列ともに結果は変わりません。
3. ネストした生成列定義は可能か
「割引額」という生成列を参照する、「料金」という生成列の定義を試みます。
CREATE TABLE m_price ( |
こちらもエラーになります。「他の生成列を参照することはできません」という内容です。デジャブ感があるのは、「生成列で自分自身の列を参照できるか」節でもこのメッセージを見たためです。格納生成列、仮想生成列のどちらも同じ結果になります。
ドキュメントにも、 The generation expression can refer to other columns in the table, but not other generated columns. (生成式はテーブル内の他の列を参照できますが、他の生成列を参照することはできません。)とあるので、記載通りの挙動です。
4. 計算途中でnull値が混入したらどうなるか
例えば、総額=単価x数量 という生成列を定義します。この時、単価がNULLの場合にはどのように挙動するか確かめます。
CREATE TABLE t_order ( |
unit_priceがNULLの場合は、total_priceもNULLという結果です。SQL的に自然な挙動ですね。回避するには、unit_priceやquantityにNOT NULL制約を付けたり、COALESCEでNULLを実値に置き換える必要があります。
5. NOT NULL制約を付けることができるか
ちょっとテクニカルなテーブル定義に書き換えます。生成列のtotal_priceのみNOT NULL制約を付けて、ソースのunit_price, quantity はNULL許容にします。
postgres=# CREATE TABLE t_order ( |
これは成功します。生成列でもNOT NULL制約を付与は可能です。
続いて、先ほどと同様 unit_price に NULL 値を含んだINSERT文を実行します。格納生成列の場合はインサート時に計算しますが、仮想生成列の場合は読み取り時に計算されるため、検知できない気がしますが…?
postgres=# INSERT INTO t_order (unit_price, quantity) VALUES (NULL, 5); |
なんと、仮想生成列でも、インサート時に登録が失敗しました。仮想生成列も登録時に計算しているようです。仮想生成列は格納生成列に比べて、登録時に計算しないから高速というのは、必ずしも成立する話ではないように感じます。
ちなみに、格納生成列でもこの挙動は変わりません(こちらは直感的な動作かなと思います)。
この検証は「11」節ではさらに詳しく調べています。
6. 生成列は一意制約を付けることができるか
格納生成列の場合は成功します。
postgres=# CREATE TABLE t_order_detail ( |
仮想生成の場合は、失敗します。
postgres=# CREATE TABLE t_order_detail ( |
これは後述するインデックスのサポート有無の挙動の差でしょう。なお、これまた後述する式インデックスに一意制約をつけることで、実質的に、仮想生成列に一意制約をつけることはできます。
7. 生成列はインデックスに使えるか
格納生成列、仮想生成列それぞれにインデックスを追加してみます。
CREATE TABLE m_user ( |
仮想生成列へのインデックス追加はサポートされていないようです(まぁ実体がないのでそれはそう)。直接的な回避方法としては、式インデックスを使うことになるでしょう。つまり、仮想生成列の式定義と同じ式を、式インデックスに指定します。
例えば、以下のように idx_02_m_user の定義を変更します。テーブル作成と式が重複するのですが仕方なしです。
postgres=# CREATE INDEX idx_02_m_user ON m_user ((last_name || ' ' || first_name)); |
実行計画レベルで、式インデックスが使われていることを確認できました。多少の回避方法が必要ですが、仮想列も事実上、インデックスを貼れると思ってよいでしょう。
8. 生成列はPKにできるか
例えば、受注明細トランで、受注番号+商品IDを組み合わせてPKにするケースを考えます(普通は、サロゲートにして欲しい案件ですが、あくまで動作確認上の”例”です)。
まず格納生成列で試します。
CREATE TABLE t_order_detail ( |
普通にPKとして扱えました。
続いて、仮想生成列で試します。
CREATE TABLE t_order_detail ( |
これはエラーになりました。仮想生成列のPKはサポートされていないようです。仮想生成列はインデックスを使えないため、PKにできないのでしょう。これについては先程の式インデックスで代替することはできません。ただし、式インデックスは、一意制約とNOT NULL制約を付与できるので、類似の機能を持たせることはできます。
注意として式インデックスでは、 :::txt による型変換が使えず CAST() で変換するなど微妙にクセがあることです。
postgres=# CREATE UNIQUE INDEX idx_01_t_order_detail |
一意性のチェックです。
postgres=# INSERT INTO t_order_detail (order_id, item_id, quantity) VALUES (1003, 202, 3); |
無事動作しました。ただし、あくまで仮想列自体に一意制約+NOT NULL制約をつけたわけではなく、仮想列と同等の定義を持った式インデックスに、一意制約+NOT NULL制約をつけたことになります。そのため、外部キー制約の参照の対象にはできないでしょう。
9. 生成列はパーティションキーに使えるか
受注日時から受注日付(yyyy-MM-dd)を生成列で作成し、それをパーティションキーとするようなケースで試します。
postgres=# CREATE TABLE t_order ( |
生成列はパーティションキーに使えないようです。この用途ですと、生成列を実カラムに戻し、アプリケーション側で明示的に設定する方が良いように思えます(アプリケーションの代わりにトリガーを使用しても良いですが、さすがにテクニカル過ぎるでしょう)。仮想生成列でも同じ結果になります。
もちろんドキュメントにも、A generated column cannot be part of a partition key.(生成列はパーティションキーには利用できません。)と書かれています。
10. テーブル作成後に後から生成列を追加できるか
m_user に格納生成列、仮想生成列の順番で足してみます。
postgres=# CREATE TABLE m_user ( |
結果は成功でした。ちなみに、ALTER文実行前にはBEGINEを実行し、別プロセスでpg_locks を確認したところ、どちらも AccessExclusiveLock を取っていました。格納生成列は既存行が多ければ長時間、参照もできないので注意が必要です。仮想生成列はメタデータの書き換えのみで済むため、AccessExclusiveLock を取りますが一瞬で終わります。
11. NOT NULL制約を付けた生成列を後から追加すると処理時間はどうなるか
以下の t_order に1万件のダミーデータを登録し、[格納|生成] x[NOT NULL有無]の4パターンで処理時間を計測しました。
-- 初期テーブル |
検証は以下のフローです。
SET autovacuum = OFF; |
| 検証パターン | 処理結果 |
|---|---|
| 格納生成列(NULL許容) | 14.4秒 |
| 格納生成列(NOT NULL) | 30.3秒 |
| 仮想生成列 (NULL許容) | 0.006秒 |
| 仮想生成列(NOT NULL) | 10.2秒 |
格納生成列もNOT NULL化すると少し処理時間が増します。理由を深く調査はしていませんが、NOT NULL計算分が上乗せになるからでしょう。そして、仮想生成列ですが、NOT NULL制約を追加すると大幅に時間がかかります。これはおそらくテーブルフルスキャンでNOT NULLにならないかチェックするからでしょう。
12. 生成列の定義変更はできるか
ドキュメントを読む限り、生成列の定義を直接変更することはできないように思えます(文法の読み取りが間違っていたらご指摘ください)。
そのため、一度そのカラムを削除してから作り直すことになると思われます。例えば、先程の email_lower をいう検索専用の生成列を、さらに前後の空白をトリムする処理を追加します。
-- (1) 既存の格納列を削除 |
流れ自体は仮想生成列でも同様です。格納生成列の場合は、(2)の処理でテーブルサイズによってはかなり時間がかかると思うので、注意が必要そうです(格納生成列のまま、瞬時に切り替える手順は今のところ、テーブル単位で新旧Verを作ってリネームする方法しか思いつきませんでした。また、格納生成列をDROP & ADDするということは、統計情報も消えるということなので、インデックス項目の場合はANALYZEもしたほうが良いでしょう)。
13. 利用しているカラムをRENAME COLUMNしたらどうなるか
生成列で利用しているカラムをリネームはできるのでしょうか?試してみます。
CREATE TABLE m_user ( |
格納・仮想のどちらの生成列で利用しているカラム名を変更が成功し、挙動も問題なかったです。
\d m_user でテーブル定義を確認すると、生成列定義の列名も書き換わっていました。
postgres=# \d m_user |
リネームにも追随してくれるの、気が効いていますね。賢い。
14. 利用しているカラムをDROP COLUMNしたときどうなるか
格納生成列、仮想生成列それぞれで利用しているカラムを、DROPできるか試しました。
CREATE TABLE m_user ( |
どちらもエラーかつ、ヒントで依存しているオブジェクト(列)もCASCADEオプションで消せるとありますね。CASCADEつけて実行してみます。
-- 格納生成列で利用されているカラムを削除(CASCADE追加) |
CASCADE を利用したら、利用していた元のカラムも同時に削除されました。強力ですね…。事故不可避なので存在自体を忘れたほうが良いでしょう。
格納生成列と仮想生成列の使い分け
格納生成列ですが、最初に紹介したPostgreSQL設計ガイドラインにある通り、業務要件の変更でロジックを変更したい場合の、テーブルマイグレーション(デプロイ作業)が大変過ぎるため避けるべきは変わりませんでした。
仮想生成列は、その手の苦労は今回動かした範囲内ではあまり感じませんでした(実データの変更は伴わず、メタデータの変更のみだからです)。一方で、NOT NULL制約を付けたときの挙動には注意で、定義変更時は既存の全行をフルスキャンするような動きになっていると思われます。システムメンテナンスタイムを確保できるシステムであっても、それなりのデータ量になりえる場合は、選択しにくいでしょう(パーティションテーブルごとに定義変更できるなどの手順が確立できればまだ考えようがありますが..)。
総合すると、NOT NULLを絶対に付けないかつ、アプリケーション側でロジックが散らばるのであれば、いっそDB定義側で仮想生成列を用いて、統制を図るのも一手ではないかと感じました。一方で、将来的にNOT NULL制約をつける変更もありえなくない場合は、防御的な考えから異現時点では採用すべきでない気がします。式インデックスなどハマりどころもあるので、大規模だと利用は現時点では禁止にしたい。
みなさんの意見もいただけると嬉しいです。
まとめ
格納生成列、仮想生成列の両方について触ってみました。私の見解としては以下の意見です。
- 格納生成列は使わない
- 仮想生成列は、NOT NULL制約を付けたときの挙動は気になるけど、NOT NULL制約を絶対に付けないのであれば害は少ないので、統制が取れるなら利用してもよいのでは。大規模ではハマりどころも多いのでテックリード的な視点では、現時点では禁止にしておきたい
- どちらも、パーティションキーに使えないなど制約があるので、ドキュメントを一読することを推奨します