PostgreSQL の生成列とトリガー:どちらを使うべきか
PostgreSQL の生成列とトリガー:合計、ステータス、正規化値に対して適切な方法を選ぶ。書き込み・読み取りの速度とデバッグのトレードオフを明確にします。

派生フィールドで解決したい問題とは?
派生フィールドは、他のデータから計算できるために保存したり表示する値です。同じ計算をあらゆるクエリや画面で繰り返す代わりに、ルールを一箇所で定義して再利用します。
よくある例はイメージしやすいです:
order_totalは明細の合計から割引を引き、税を加えたもの- 日付や支払い記録に基づいた「paid」や「overdue」といったステータス
- 小文字化したメール、トリムした電話番号、検索しやすい名前などの正規化値
チームが派生フィールドを使うのは、読み取りが簡単で一貫するからです。レポートは order_total を直接選べますし、サポートは複雑なロジックをコピーせずステータスでフィルタできます。共有ルールにすることでサービス、ダッシュボード、バッチ処理間の小さな差異も減ります。
ただしリスクもあります。最大のものはデータの陳腐化で、入力が変わっても派生値が更新されないケースです。次に隠れたロジック:ルールがトリガーや関数、古いマイグレーションに埋もれて誰も思い出せないこと。三つ目は重複で、似たルールが複数箇所にあって時間とともに乖離することです。
だからこそ、PostgreSQL で生成列とトリガーのどちらを選ぶかは重要です。単に値の計算方法を選ぶだけでなく、ルールがどこに置かれるか、書き込み時のコスト、誤った数値の原因を辿る難易度を選ぶことになります。
この記事の残りでは、保守性(人が理解・変更できるか)、クエリ速度(読み取り・書き込み・インデックス)、デバッグ(値が間違っている理由の突き止め方)の3つの実用的な観点で見ていきます。
生成列とトリガー:簡単な定義
生成列とトリガーを比較するとき、実際には派生値をどこに置くかを選んでいます:テーブル定義の中か、データ変更時に走る手続き的ロジックの中か。
生成列
生成列は同じ行の他の列から計算される実際のテーブル列です。PostgreSQL では生成列は格納され(DB が計算結果をディスクに保存します)、参照する列が変わると自動で更新されます。
生成列はクエリやインデックスに対して通常の列のように振る舞いますが、直接書き込みはしません。もし保存しない計算値が必要なら、PostgreSQL では通常ビューやクエリ式を使います。
トリガー
トリガーは INSERT、UPDATE、DELETE などのイベントで動くロジックです。トリガーは変更の BEFORE または AFTER に実行でき、行ごと(FOR EACH ROW)または文ごと(FOR EACH STATEMENT)に動かせます。
トリガーはコードで動くので、単純な算術以上のことができます。他の列を更新したり、別テーブルに書き込んだり、カスタムルールを施行したり、複数行にまたがる変更に反応したりできます。
覚えやすい区別はこうです:
- 生成列は予測可能で行レベルの計算(合計、正規化されたテキスト、単純なフラグ)に向き、その行に常に一致しているべき値に使います。
- トリガーはタイミングや副作用、行横断・テーブル横断のロジック(ステータス遷移、監査ログ、在庫調整)に向きます。
制約について一つ: NOT NULL、CHECK、UNIQUE、外部キーのような組み込み制約は宣言的で明確ですが制限があります。たとえば CHECK 制約はサブクエリで他行を参照できません。ルールが現在の行以上に依存するなら、通常はトリガーや設計の見直しに至ります。
AppMaster のようなビジュアルツールで構築すると、この違いは「データモデルの式」スタイルのルールか、レコード変更時に動く「ビジネスプロセス」ルールかにきれいに対応します。
保守性:どちらが長く読みやすく残るか?
保守性の主な違いはルールがどこにあるかです。
生成列はロジックをデータ定義のそばに置きます。誰かがテーブルスキーマを開けば、その値を生み出す式を見られます。
トリガーだとルールはトリガー関数に移動します。どのテーブルやイベントがそれを呼ぶかも知っておく必要があります。数ヶ月後には「読みやすさ」はしばしば「データベースを探し回らずにルールを理解・発見できるか」を意味し、生成列の方が勝つことが多いです。
トリガーも関数を小さく焦点を絞っておけばきれいに保てます。問題になるのはトリガー関数が無関係なルールの捨て場になったときです。動作はしても、理由付けが難しくなり、変更がリスクになります。
変更もまた圧力点です。生成列だと更新は通常単一の式を変えるマイグレーションで済みます。レビューやロールバックも簡単です。トリガーは関数本体とトリガー定義の両方にまたがる調整、そしてバックフィルや安全性チェックの追加手順が必要になることが多いです。
ルールを見つけやすく保つための習慣:
- 列、トリガー、関数に業務ルールに沿った名前を付ける。
- 意図を説明する短いコメントを追加する(単なる計算式だけでなく)。
- トリガー関数は小さく保つ(一つのルール、一つのテーブル)。
- マイグレーションをバージョン管理し、レビューを必須にする。
- 定期的にスキーマ内のトリガー一覧を出して不要なものを削除する。
AppMaster でも同じ考えが当てはまります:すばやく監査できるルールを好み、書き込み時に走る「隠れた」ロジックは最小限にしてください。
クエリ速度:読み取り・書き込み・インデックスに何が変わるか?
性能上の問いは要するに「コストを読み取り側で払うか、書き込み側で払うか」です。
生成列は行が書き込まれるときに計算され保存されるので、読み取りは速いです。代わりに INSERT や、その入力に触れる UPDATE のたびに生成値を計算する必要があります。
トリガー方式は通常派生値を通常カラムに保存してトリガーで更新します。読み取りも速いですが、書き込みが遅く予測不可能になりがちです。トリガーは行ごとに追加の作業を行い、バルク更新時にはそのオーバーヘッドが顕著になります。
インデックスは格納された派生値が重要になる箇所です。派生フィールドで頻繁にフィルタやソートをするなら(正規化メール、合計、ステータスコードなど)、インデックスが遅いスキャンを高速な検索に変えます。生成列では生成された値に直接インデックスを張れます。トリガーでも維持されたカラムにインデックスを張れますが、トリガーが正しさを保つことに依存します。
値をクエリ内で計算する場合(例えば WHERE 句内)、多数の行で再計算を避けるために式インデックスが必要になることがあります。
バルクインポートや大きな更新はホットスポットになりがちです:
- 生成列は影響を受ける各行に一貫した計算コストを追加します。
- トリガーは計算コストに加えてトリガーオーバーヘッドがあり、非効率なロジックだとコストが増幅します。
- 大規模更新ではトリガー処理がボトルネックになり得ます。
実用的な選び方は実際のホットスポットを見ることです。テーブルが読み取り中心で派生フィールドがフィルタでよく使われるなら、保存された値(生成列でもトリガー維持の列でも)とインデックスが勝つことが多いです。書き込みが多い(イベント、ログ)の場合は、行ごとの作業を増やす前に注意してください。
デバッグ:間違った値の原因を見つける方法
派生フィールドが間違っているときは、まずバグを再現できるようにします。問題を引き起こした正確な行の状態をキャプチャし、副作用を追わないクリーンなトランザクションで同じ INSERT や UPDATE を再実行します。
素早く絞る方法は「値は決定的な式由来か、書き込み時ロジック由来か?」と問うことです。
生成列は同じ入力に対して一貫して失敗します。式が間違っていれば同じ入力では常に間違います。よくある落とし穴は NULL の扱い(一つの NULL が計算全体を NULL にする)、暗黙のキャスト(text→numeric)、ゼロ除算のような端ケースです。環境間で結果が異なるなら、照合順序や拡張、式を変えたスキーマ変更を疑います。
トリガーはタイミングやコンテキストに依存するためもっと入り組んだ失敗になります。期待通りに発火しない(イベントやテーブルが違う、WHEN 句が不適切)ことがあるし、トリガーチェーンで何度も発火することもあります。セッション設定、search_path、参照する他テーブルの差異もバグの原因になります。
派生値が間違って見えるときのチェックリスト:
- 最小の INSERT/UPDATE で再現する。
- 派生列の横に入力列を SELECT して入力を確認する。
- 生成列なら同じ式を SELECT で実行して比較する。
- トリガーなら一時的に RAISE LOG 通知を入れるかデバッグ用テーブルに書き込む。
- 環境間でスキーマとトリガー定義を比較する。
既知の結果を持つ小さなテストデータセットは驚きを減らします。例えば NULL 割引の注文と割引 0 の注文を作り、合計が期待通りか確認します。ステータス遷移も同様に、意図した更新でのみ起きるか検証します。
選び方:決定の流れ
いくつかの実用的な質問に答えれば、最良の選択はたいてい明らかになります。
ステップ 1–3:まず正しさ、その次にワークロード
順に検討してください:
- 値は例外なく他の列と常に一致する必要がありますか?もしそうなら、アプリ任せにせずデータベースで強制してください。
- 式は決定的で同じ行の列だけに基づきますか(例えば
lower(email)やprice * quantity)?もしそうなら、生成列が一般に最もクリーンです。 - その値は主に読み取り(フィルタ、ソート、レポート)で使われますか、それとも書き込みが多いですか(大量の INSERT/UPDATE)?生成列はコストを書き込みに移すので、書き込みが多いテーブルでは影響が早く現れるかもしれません。
ルールが他行や他テーブル、時間感度のあるロジック(例:「支払いが7日以内にないなら overdue にする」)に依存するなら、トリガーの方が適していることが多いです。トリガーはより豊富なロジックを実行できます。
ステップ 4–6:インデックス、テスト、単純さを保つ
次に値の使い方と検証方法を決めます:
- それで頻繁にフィルタやソートをしますか?するならインデックスを計画し、手法がそれをきれいにサポートするか確認します。
- 変更をどうテスト・観察しますか?生成列は式が一箇所にあるので理由づけが簡単です。トリガーは値が「副作用で」変わるため、的を絞ったテストと明確なログが必要です。
- 制約を満たす最も単純な選択をする。生成列で済めば、通常は保守が簡単です。クロスローやマルチステップのステータス変更、副作用が必要ならトリガーを受け入れつつ、それを小さく名前を明確に保ってください。
直感的なチェック:ルールを一文で説明でき、現在行だけを使うなら生成列から始めてください。ワークフローを説明するならトリガー領域です。
合計や正規化値に生成列を使う
生成列は値が同じ行の他の列から完全に派生し、ルールが安定している場合に有効です。このときが最も単純に感じられます:式がテーブル定義にあり、PostgreSQL が一貫して保ちます。
典型的な例は正規化値(小文字化・トリムした検索キー)や単純な合計(subtotal + tax - discount)です。例えば orders テーブルに subtotal、tax、discount を持ち、total を生成列として出すと、アプリコードに頼らずすべてのクエリが同じ値を見られます。
式を書くときは冗長にならない防御的な書き方を心がけてください:
COALESCEで NULL を扱い、合計が予期せず NULL にならないようにする。- 整数と数値を混ぜるときは意図的にキャストする。
- 丸めは一箇所で行い、丸めルールを式内で文書化する。
- タイムゾーンやテキスト処理(小文字化、トリム、空白置換)を明示的にする。
- 巨大な式より、いくつかの補助列に分けることを好む。
インデックスはその派生値で実際にフィルタや結合をする場合にのみ有効です。total にインデックスを張っても総じて無駄になることがありますが、email_normalized のようなキーには価値があります。
スキーマ変更は生成式が他列に依存するため重要です。列名を変えたり型を変更すると式が壊れる可能性があり、これは良い失敗モードです。移行時に壊れるので、静かに間違ったデータを書き続けるより早く気づけます。
式が複雑になりすぎたら(多くの CASE やビジネスルールが増える)、信号とみなしてください。部分を別カラムに分けるか、可読性とテスト性を保つためにアプローチを切り替えます。AppMaster で PostgreSQL スキーマをモデリングするなら、生成列は一行で説明できるルールに向いています。
ステータスやクロスローのルールにトリガーを使う
トリガーはフィールドが現在の行以上のものに依存するときに適したツールです。ステータスフィールドが典型です:注文が "paid" になるのは少なくとも一つの成功した支払いが存在してから、またはすべてのタスクが終わったときにチケットが "resolved" になるといったルールは行やテーブルを跨ぎます。生成列はそうした関連レコードを読むことができません。
良いトリガーは小さくて地味です。ガードレールのように扱い、第二のアプリケーション層にしないでください。
トリガーを予測可能に保つ
トリガーの隠れた書き込みが住みにくさの元です。単純な慣習が他の開発者に見つけやすくします:
- 一つの目的につき一つのトリガー(ステータス更新だけ、合計や監査や通知を一緒にしない)。
- 明確な名前(例:
trg_orders_set_status_on_payment)。 - タイミングの一貫性:入力を修正するなら BEFORE、保存後に反応するなら AFTER を使う。
- ロジックは一つの関数にまとめ、読み切れる短さにする。
現実的なフローの例:payments が succeeded に更新される。payments の AFTER UPDATE トリガーは、当該注文に少なくとも一つの succeeded 支払いがあり未払残高がない場合に orders.status を paid に更新する。
エッジケースの計画
トリガーはバルク変更で動作が変わることがあります。コミット前にバックフィルや再実行をどう扱うか決めてください。既存データのステータスを再計算する一時的な SQL ジョブは、行ごとにトリガーを発火させるよりも明快なことが多いです。また単一注文のステータスを再計算するストアドプロシージャなど、安全な "再処理" パスを定義しておくと良いです。再実行しても状態が変わらないよう冪等性を考慮してください。
最後に、単純な許容値なら制約やアプリロジックが適切な場合があります。AppMaster のようなツールでは、多くのワークフローはビジネスロジック層で見やすく管理する方が簡単で、データベーストリガーは狭いセーフティネットに留めることが多いです。
よくあるミスと落とし穴
派生フィールド周りの多くの痛みは自己造成です。最も大きな罠は、デフォルトでより複雑な道具を選ぶことです。まず「これは同じ行の純粋な式で表せるか?」と問ってください。答えがイエスなら生成列が多くの場合穏やかな選択です。
別のよくある間違いはトリガーを第二のアプリケーション層にしてしまうことです。「ステータスを設定するだけ」に始まり、価格ルールや例外、特殊ケースが増えてテストがないと小さな編集で古い振る舞いが壊れます。
しばしば現れる落とし穴:
- 生成列で済む値にトリガーを使ってしまい、生成列にしておけば自己文書化で済んだケース。
- あるコードパス(チェックアウト)で保存合計を更新して、別のコードパス(管理者の編集、インポート、バックフィル)で忘れること。
- 同じ注文行を二つのトランザクションが同時に更新し、トリガーが上書きや重複適用するなどの同時実行性の無視。
- 「念のため」にすべての派生フィールドにインデックスを張ること、特に頻繁に変わる値に対しては避ける。
- めったに検索しない正規化文字列のように、読み取り時に計算すれば良いものを保存してしまう。
小さな例:order_total_cents を保存し、サポートが明細を調整できるとします。サポートツールが明細を更新して合計を触らないと合計は古くなります。後からトリガーを追加しても、履歴行や部分返金などの端ケースに対処する必要があります。
AppMaster のようなビジュアルツールで作る場合も同じです:ビジネスルールを一箇所で見えるように保ち、派生値の更新を複数のフローにばら撒かないでください。
実装前の簡単なチェック
生成列とトリガーのどちらかを選ぶ前に、ルールを簡単にストレステストしてください。
まずルールが何に依存するか問います。現在の行の列からのみ計算できるなら(正規化電話番号、小文字化したメール、line_total = qty * price など)、生成列はたいてい扱いやすく、ロジックがテーブル定義のそばに残ります。
ルールが他行や他テーブルに依存するなら(最後の支払いでステータスが変わる、最近のアクティビティでアカウントフラグを立てるなど)、トリガー領域か、あるいは読み取り時に計算することを検討してください。
簡単なチェックリスト:
- 値は現在の行だけから導けるか?ルックアップ不要か?
- それで頻繁にフィルタやソートをするか?
- ルールを変えたあと履歴データを再計算する必要があるか?
- 開発者が定義を見つけて2分以内に説明できるか?
- ルールが動作することを示す少数のサンプル行を持っているか?
運用面も考えてください。バルク更新、インポート、バックフィルはトリガーが人を驚かせる場面です。トリガーは行ごとに発火するので、設計が悪いと遅いロード、ロック競合、半分だけ更新された派生値などが発生します。
実用的なテストは単純です:ステージングテーブルに 10,000 行をロードし、通常のインポートを実行して何が計算されるか検証し、重要な入力列を更新して派生値が正しく保たれるか確認します。
AppMaster でアプリを作るなら同じ原則が当てはまります:単純な行ベースのルールはデータベースの生成列に、クロステーブルのマルチステップ変更は Business Process に置いて、繰り返しテストできるようにしてください。
現実的な例:注文、合計、ステータス
シンプルなストアを想像してください。orders テーブルに items_subtotal、tax、total、そして payment_status があり、「なぜこの注文が未払いなのか?」を素早く答えられる状態にしたいとします。
オプション A:合計に生成列、ステータスは素直に格納
同じ行の値だけで決まる金額計算には生成列がきれいに合います。items_subtotal と tax を通常カラムとして保持し、total を items_subtotal + tax のような生成列にすることで、テーブル上にルールが見えて書き込み時の隠れたロジックを避けられます。
payment_status はアプリが支払いの作成時に設定する通常カラムとして残すこともできます。自動化は弱くなりますが、行を読むときに理由を追いやすい単純な方法です。
オプション B:支払いに駆動されるステータス変更にトリガーを使う
payments テーブルを追加すると、ステータスはもはや orders の単一行だけの問題ではなくなります。成功した支払い、返金、チャージバックといった関連行に依存します。payments のトリガーが支払いが変わるたびに orders.payment_status を更新できます。
この場合はバックフィル計画を立ててください:既存注文の payment_status を再計算する一度きりのスクリプトと、バグが入ったときに再実行できる繰り返し可能なジョブを用意します。
サポートが「なぜこの注文が未払いなのか?」を調べるとき、オプション A だと通常アプリとその監査で調べます。オプション B だとデータベースロジックにも目を向ける必要があります:トリガーは発火したか、失敗したか、条件でスキップされたか?
リリース後は次のような信号を監視します:
paymentsの遅い更新(トリガーは書き込みに仕事を追加します)ordersへの予期しない更新(ステータスが想定より頻繁に変わる)totalは正しいのにステータスが違う行(ロジックが複数箇所に分かれている)- ピーク時のデッドロックやロック待ち
次のステップ:単純な方法を選び、ルールを見える化する
SQL に触る前にルールを平易な言葉で書いてください。「注文合計は明細の合計から割引を引く」が明確です。「支払い日時があり残高がゼロならステータスは paid」も明確です。一文で説明できないなら、それはレビューとテストがしやすい場所に置くべきで、データベースの手早いハックに押し込むべきではありません。
迷ったら実験扱いにしてください。テーブルの小さなコピーを作り、現実っぽい小さなデータセットを入れて両方のアプローチを試します。実際に気にする点(読み取りクエリ、書き込み速度、インデックスの使われ方、後で理解しやすいか)を比較します。
簡潔なチェックリスト:
- 両方のオプションをプロトタイプして、一般的な読み取りのクエリプランを確認する。
- 書き込み負荷の高いテスト(インポート、更新)を実行して、値を最新に保つコストを測る。
- バックフィル、NULL、丸め、端ケースをカバーする小さなテストスクリプトを追加する。
- 長期的にロジックを誰が管理するか(DBA、バックエンド、プロダクト)を決め、その選択を文書化する。
内部向けツールやポータルを作る場合、可視性は正確さと同じくらい重要です。AppMaster (appmaster.io) では、チームは単純で行ベースのルールをデータモデルに近く置き、マルチステップの変更は Business Process に入れることでコードレビュー時にロジックが読みやすく保たれることが多いです。
最後に時間を節約すること:真実がどこにあるか(テーブル、トリガー、アプリロジック)と、安全に再計算する方法(バックフィルの手順)を文書化しておいてください。
よくある質問
多くのクエリや画面で同じ値が必要で、共有された定義を持ちたいときに派生フィールドを使います。正規化されたキー、単純な合計、一貫したフラグなど、フィルタやソート、表示で頻繁に使う値に向いています。
値が同じ行の他の列だけに依存し、常にそれらと一致するべきなら生成列を選んでください。テーブルスキーマ内にルールが見える形で残り、書き込み時の隠れたコード経路を避けられます。
ルールが他の行や他テーブルに依存する場合、あるいは関連レコードの更新や監査記録の作成など副作用が必要な場合はトリガーが適しています。タイミングやコンテキストが重要なワークフロー的遷移にもトリガーが向きます。
生成列は同じ行の列しか参照できないため、注文明細の合計のように子行を合算することはできません。その場合はクエリで計算するか、トリガーで維持するか、必要な入力が同じ行に来るようスキーマを再設計します。
生成列は書き込み時に計算して保存するため、読み取りは速くインデックスも容易ですが、INSERT や UPDATE のたびに計算コストが発生します。トリガーも書き込みにコストを移しますが、ロジックが複雑だったりチェーンで動くと予測しにくく遅くなることがあります。
正規化されたメールやステータスコードなどで頻繁にフィルタ、結合、ソートするならインデックスを付けてください。表示だけで検索しない値に対するインデックスは、書き込みのオーバーヘッドを増やすだけの場合があります。
生成列はルールがテーブル定義内にあり、人が自然に見る場所に残るため通常は保守が楽です。トリガーも狭い目的で明確に命名された小さな関数にしておけば保守可能ですが、そうでないと複雑になりやすいです。
生成列では NULL 処理、型キャスト、丸めルールの誤りがよく見られます。トリガーでは発火しない、複数回発火する、予期しない順で動く、環境差によるセッション設定依存などが原因になります。
まず問題を再現できるように当該 INSERT/UPDATE を確保し、入力列と派生列を並べて確認します。生成列なら同じ式を SELECT で実行して一致するか調べ、トリガーなら関数とトリガー定義を点検して最小限のログやデバッグ用テーブルで挙動を確認します。
ルールを一文で言えて、現在行だけを使うなら生成列が強いデフォルトです。ワークフローを説明しているならトリガーか読み取り時計算を検討し、ロジックを一箇所にまとめてテスト可能にしてください。AppMaster では、単純な行ベースのルールはデータモデルの近くに置き、クロステーブルのワークフローは Business Process に入れることが多いです。


