PostgreSQLのトリガーとは、テーブルへのINSERT・UPDATE・DELETE・TRUNCATEをきっかけに、決めておいた関数(トリガー関数)を自動で実行するしくみです。トリガー関数は RETURNS trigger で宣言した関数で、多くの場合PL/pgSQL(PostgreSQL標準の手続き型言語)で書きます。updated_at の自動更新、変更履歴の記録、入力チェックなどを、アプリ側のコードに頼らずデータベースだけで確実に行えます。

この記事では、「いつ・何に対して・どの変数が使えるか」を1枚にまとめた早見表と、そのまま貼って動くテンプレ7本(updated_at、jsonb差分の監査ログ、論理削除、在庫チェック、WHEN条件、遷移テーブル、ビューへのINSTEAD OF)を、PostgreSQL 16.15で実行した結果つきで紹介します。さらに、同じタイミングのトリガーの実行順、無限再帰の止め方、1万件UPDATEでトリガーあり・なしの処理時間の比較、実際のエラー文、トリガーを使わない方がよい場面の判断表までまとめました。構文はPostgreSQL 17・18でも同じです(2026年9月時点の公式ドキュメントはPostgreSQL 18版)。

PostgreSQLトリガーの早見表

タイミング × 単位 × イベントで使える組み合わせ

タイミング イベント 行単位(FOR EACH ROW) 文単位(FOR EACH STATEMENT)
BEFORE INSERT / UPDATE / DELETE テーブル・外部テーブル テーブル・ビュー・外部テーブル
BEFORE TRUNCATE ― テーブル・外部テーブル
AFTER INSERT / UPDATE / DELETE テーブル・外部テーブル テーブル・ビュー・外部テーブル
AFTER TRUNCATE ― テーブル・外部テーブル
INSTEAD OF INSERT / UPDATE / DELETE ビューのみ ―

(出典:PostgreSQL公式ドキュメント CREATE TRIGGER の表を日本語化)

トリガー関数の中で使える主な変数

変数 型 中身 使える場面
NEW record 新しい行 行単位の INSERT / UPDATE。DELETEと文単位ではNULL
OLD record 変更前の行 行単位の UPDATE / DELETE。INSERTと文単位ではNULL
TG_OP text INSERT / UPDATE / DELETE / TRUNCATE 常に
TG_WHEN text BEFORE / AFTER / INSTEAD OF 常に
TG_LEVEL text ROW / STATEMENT 常に
TG_NAME name トリガー名 常に
TG_TABLE_NAME name テーブル名 常に
TG_TABLE_SCHEMA name スキーマ名 常に
TG_RELID oid テーブルのOID 常に
TG_NARGS / TG_ARGV[] int / text[] CREATE TRIGGERで渡した引数(0始まり) 常に

戻り値の意味

トリガーの種類 RETURN NEW RETURN NULL RETURN OLD
BEFORE ROW の INSERT / UPDATE NEWの内容で書き込む(書き換え可) その行の処理をスキップ(エラーにはならない) ―
BEFORE ROW の DELETE ― その行の削除をスキップ 削除を続行
INSTEAD OF ROW 処理した扱いになり件数に数える 処理しなかった扱い(件数0) DELETEで処理した扱い
AFTER ROW / 文単位すべて 戻り値は無視される(慣習で RETURN NULL) 同左 同左

要点は「BEFORE ROWだけが戻り値で行を書き換えたり止めたりできる」ことです。AFTERや文単位のトリガーで何を返しても、結果は変わりません。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PostgreSQLトリガー関数の書き方|PL/pgSQLテンプレ集と早見表」

トリガーの基本:関数とトリガーの2つを作る

PostgreSQLでは、「トリガー関数」と「トリガー」を別々に作ります。関数は使い回せるので、1つの set_updated_at() を何十ものテーブルに付けられます。

-- ① トリガー関数:引数なし、戻り値は trigger
CREATE OR REPLACE FUNCTION 関数名() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  -- 処理
  RETURN NEW;  -- または NULL / OLD
END;
$$;

-- ② トリガー:どのテーブルの、いつ・何で動かすか
CREATE [OR REPLACE] TRIGGER トリガー名
  { BEFORE | AFTER | INSTEAD OF } { INSERT | UPDATE [OF 列, ...] | DELETE | TRUNCATE } [OR ...]
  ON テーブル名
  [REFERENCING { OLD | NEW } TABLE AS 名前]
  [FOR EACH { ROW | STATEMENT }]       -- 省略時は STATEMENT
  [WHEN (条件)]
  EXECUTE FUNCTION 関数名(引数...);
書き方のポイント 内容
EXECUTE FUNCTION PostgreSQL 11以降の書き方。EXECUTE PROCEDURE も動きますが、公式では非推奨(歴史的な表記)とされています
CREATE OR REPLACE TRIGGER PostgreSQL 14以降。それより前は DROP TRIGGER IF EXISTS してから作り直します
FOR EACH の省略 省略すると文単位(STATEMENT)になります。行ごとに動かしたいときは必ず FOR EACH ROW を書きます
関数の引数 トリガー関数自体は引数なしで宣言し、値は EXECUTE FUNCTION f('a', 'b') で渡して TG_ARGV[0] で受け取ります

テンプレ1:updated_at を自動更新する

もっともよく使うトリガーです。BEFORE UPDATE の行トリガーで NEW.updated_at を書き換えます。

CREATE TABLE tasks (
  id serial PRIMARY KEY,
  title text NOT NULL,
  done boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  NEW.updated_at := now();
  RETURN NEW;
END;
$$;

CREATE TRIGGER trg_tasks_updated_at
  BEFORE UPDATE ON tasks
  FOR EACH ROW
  EXECUTE FUNCTION set_updated_at();

INSERT INTO tasks (title, created_at, updated_at)
VALUES ('資料作成', '2026-10-01 09:00+09', '2026-10-01 09:00+09');
UPDATE tasks SET done = true WHERE id = 1;
SELECT id, title, done, created_at, updated_at > created_at AS updated FROM tasks;

実行結果(PostgreSQL 16.15、タイムゾーンUTC):

 id |  title   | done |       created_at       | updated 
----+----------+------+------------------------+---------
  1 | 資料作成 | t    | 2026-10-01 00:00:00+00 | t
(1 row)

RETURN NEW を忘れると後述のエラーになり、RETURN NULL にすると更新そのものがスキップされます。now() はトランザクション開始時刻なので、同じトランザクション内の更新はすべて同じ時刻になります。1行ごとの実時刻が必要なら clock_timestamp() を使います。

テンプレ2:変更履歴(監査ログ)を jsonb の差分で残す

AFTER の行トリガーで、変更前後の行を jsonb に変換して記録します。UPDATEのときは「変わった列だけ」を diff に入れ、値が変わらないUPDATEは記録しません。

CREATE TABLE members (
  id int PRIMARY KEY,
  name text NOT NULL,
  email text NOT NULL,
  plan text NOT NULL DEFAULT 'free'
);

CREATE TABLE audit_log (
  id bigserial PRIMARY KEY,
  table_name text NOT NULL,
  op text NOT NULL,
  row_id text,
  old_data jsonb,
  new_data jsonb,
  diff jsonb,
  changed_by text NOT NULL DEFAULT current_user,
  changed_at timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION audit_row() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
  v_old jsonb;
  v_new jsonb;
  v_diff jsonb;
BEGIN
  IF TG_OP IN ('UPDATE', 'DELETE') THEN
    v_old := to_jsonb(OLD);
  END IF;
  IF TG_OP IN ('INSERT', 'UPDATE') THEN
    v_new := to_jsonb(NEW);
  END IF;
  IF TG_OP = 'UPDATE' THEN
    SELECT jsonb_object_agg(n.key, jsonb_build_object('old', o.value, 'new', n.value))
      INTO v_diff
      FROM jsonb_each(v_new) AS n
      JOIN jsonb_each(v_old) AS o USING (key)
     WHERE n.value IS DISTINCT FROM o.value;
    IF v_diff IS NULL THEN
      RETURN NULL;  -- 値が変わっていない UPDATE は記録しない
    END IF;
  END IF;
  INSERT INTO audit_log (table_name, op, row_id, old_data, new_data, diff)
  VALUES (TG_TABLE_NAME, TG_OP,
          COALESCE(v_new ->> 'id', v_old ->> 'id'),
          v_old, v_new, v_diff);
  RETURN NULL;  -- AFTER トリガーの戻り値は無視される
END;
$$;

CREATE TRIGGER trg_members_audit
  AFTER INSERT OR UPDATE OR DELETE ON members
  FOR EACH ROW
  EXECUTE FUNCTION audit_row();

INSERT INTO members VALUES (1, '山田', 'yamada@example.com', 'free');
UPDATE members SET plan = 'pro', email = 'y.yamada@example.com' WHERE id = 1;
UPDATE members SET plan = 'pro' WHERE id = 1;   -- 値は変わらない
DELETE FROM members WHERE id = 1;
SELECT id, op, row_id, diff FROM audit_log ORDER BY id;
SELECT id, op, old_data FROM audit_log WHERE op = 'DELETE';

実行結果:

 id |   op   | row_id |                                                      diff                                                      
----+--------+--------+----------------------------------------------------------------------------------------------------------------
  1 | INSERT | 1      | 
  2 | UPDATE | 1      | {"plan": {"new": "pro", "old": "free"}, "email": {"new": "y.yamada@example.com", "old": "yamada@example.com"}}
  3 | DELETE | 1      | 
(3 rows)

 id |   op   |                                 old_data                                  
----+--------+---------------------------------------------------------------------------
  3 | DELETE | {"id": 1, "name": "山田", "plan": "pro", "email": "y.yamada@example.com"}
(1 row)

UPDATEは4回中、値が変わった1回だけが記録されています。TG_TABLE_NAME を使っているので、この関数は別のテーブルにもそのまま付けられます(主キー列名が id の前提)。

ログに残すユーザーIDは、アプリ側で SET LOCAL app.user_id = '42' を実行し、関数で current_setting('app.user_id', true) を読むと渡せます。記録したくない列は to_jsonb(NEW) - 'password_hash' で除けます。

テンプレ3:DELETE を論理削除に置き換える

BEFORE DELETE で RETURN NULL を返すと、元のDELETEは行われません。代わりに deleted_at を入れる UPDATE を実行します。

CREATE TABLE articles (
  id int PRIMARY KEY,
  title text NOT NULL,
  deleted_at timestamptz
);

CREATE OR REPLACE FUNCTION soft_delete() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  UPDATE articles SET deleted_at = now() WHERE id = OLD.id;
  RETURN NULL;  -- NULL を返すと元の DELETE は行われない
END;
$$;

CREATE TRIGGER trg_articles_soft_delete
  BEFORE DELETE ON articles
  FOR EACH ROW
  WHEN (OLD.deleted_at IS NULL)
  EXECUTE FUNCTION soft_delete();

INSERT INTO articles VALUES (1, 'お知らせ', NULL), (2, '更新情報', NULL);
DELETE FROM articles WHERE id = 1;
SELECT id, title, deleted_at IS NOT NULL AS is_deleted FROM articles ORDER BY id;
DELETE FROM articles WHERE id = 1;   -- もう一度削除
SELECT count(*) FROM articles;

実行結果:

DELETE 0
 id |  title   | is_deleted 
----+----------+------------
  1 | お知らせ | t
  2 | 更新情報 | f
(2 rows)

DELETE 1
 count 
-------
     1
(1 row)

1回目は DELETE 0(物理削除されず、deleted_at が入る)、2回目は WHEN 条件を満たさないのでトリガーが動かず DELETE 1(物理削除)になりました。「論理削除済みの行をもう一度消したら完全に消す」という動きです。常に物理削除させたくない場合は、WHEN を外して関数側で IF OLD.deleted_at IS NOT NULL THEN RETURN NULL; END IF; と書きます。

DELETE 0 が返るため、ORMが「削除件数0=失敗」と判断することがあります。その場合は後述のビュー+INSTEAD OFの形(テンプレ7)にすると、件数が1と返ります。

テンプレ4:在庫数をチェックして RAISE EXCEPTION で止める

条件を満たさないときに RAISE EXCEPTION を投げると、その文全体がロールバックされます。エラーコードとヒントも付けられます。

CREATE TABLE items (
  id int PRIMARY KEY,
  name text NOT NULL,
  stock int NOT NULL
);
CREATE TABLE orders (
  id serial PRIMARY KEY,
  item_id int NOT NULL REFERENCES items(id),
  qty int NOT NULL CHECK (qty > 0)
);

CREATE OR REPLACE FUNCTION reserve_stock() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
  v_stock int;
BEGIN
  SELECT stock INTO v_stock FROM items WHERE id = NEW.item_id FOR UPDATE;
  IF NOT FOUND THEN
    RAISE EXCEPTION '商品が見つかりません: item_id=%', NEW.item_id;
  END IF;
  IF v_stock < NEW.qty THEN
    RAISE EXCEPTION '在庫不足です: item_id=%, 在庫=%, 注文=%', NEW.item_id, v_stock, NEW.qty
      USING ERRCODE = 'check_violation',
            HINT = '注文数を減らすか、入荷を待ってください';
  END IF;
  UPDATE items SET stock = stock - NEW.qty WHERE id = NEW.item_id;
  RETURN NEW;
END;
$$;

CREATE TRIGGER trg_orders_reserve
  BEFORE INSERT ON orders
  FOR EACH ROW
  EXECUTE FUNCTION reserve_stock();

INSERT INTO items VALUES (10, 'ノート', 5);
INSERT INTO orders (item_id, qty) VALUES (10, 3);
SELECT * FROM items;
INSERT INTO orders (item_id, qty) VALUES (10, 3);
SELECT * FROM items;
SELECT count(*) AS order_count FROM orders;

実行結果:

 id |  name  | stock 
----+--------+-------
 10 | ノート |     2
(1 row)

ERROR:  在庫不足です: item_id=10, 在庫=2, 注文=3
HINT:  注文数を減らすか、入荷を待ってください
CONTEXT:  PL/pgSQL function reserve_stock() line 10 at RAISE
 id |  name  | stock 
----+--------+-------
 10 | ノート |     2
(1 row)

 order_count 
-------------
           1
(1 row)

2回目の注文はエラーになり、在庫も注文件数も変わっていません。FOR UPDATE で商品行をロックしているので、同時に2つの注文が来ても在庫がマイナスになりません。ERRCODE = 'check_violation'(SQLSTATE 23514)にしておくと、アプリ側でCHECK制約違反と同じように扱えます。

「在庫が0以上」のような1行で完結するルールは、トリガーより CHECK (stock >= 0) の方がシンプルです。このテンプレは「別テーブルを見て判断し、さらに別テーブルを更新する」ケース向けです。

テンプレ5:WHEN 条件で「特定の列が変わったときだけ」動かす

UPDATE OF 列名 と WHEN (OLD.x IS DISTINCT FROM NEW.x) を組み合わせると、関数を呼ぶ前に絞り込めます。関数の中でIFを書くより呼び出し回数が減るので効率的です。

CREATE TABLE task_status_log (task_id int, old_done boolean, new_done boolean, at timestamptz DEFAULT now());

CREATE OR REPLACE FUNCTION log_task_status() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO task_status_log (task_id, old_done, new_done) VALUES (NEW.id, OLD.done, NEW.done);
  RETURN NULL;
END;
$$;

CREATE TRIGGER trg_tasks_status
  AFTER UPDATE OF done ON tasks
  FOR EACH ROW
  WHEN (OLD.done IS DISTINCT FROM NEW.done)
  EXECUTE FUNCTION log_task_status();

INSERT INTO tasks (title) VALUES ('請求書確認');           -- id = 2
UPDATE tasks SET title = '請求書の確認' WHERE id = 2;      -- done 列を含まない
UPDATE tasks SET done = false WHERE id = 2;               -- 値が同じ
UPDATE tasks SET done = true WHERE id = 2;                -- 値が変わる
SELECT task_id, old_done, new_done FROM task_status_log;

実行結果:

 task_id | old_done | new_done 
---------+----------+----------
       2 | f        | t
(1 row)

3回のUPDATEのうち、実際に done が変わった1回だけが記録されました。

指定 判定のしかた 注意
UPDATE OF done UPDATE文の SET に done が書かれているか 値が同じでも SET にあれば対象。BEFOREトリガーが done を書き換えても対象外
WHEN (OLD.done IS DISTINCT FROM NEW.done) 値が実際に変わったか NULLを含めて比べるなら <> ではなく IS DISTINCT FROM
WHEN内の参照 INSERTは NEW のみ、DELETEは OLD のみ 文単位トリガーには WHEN で NEW/OLD を書けない

テンプレ6:文単位トリガーと遷移テーブル(REFERENCING NEW TABLE)

REFERENCING NEW TABLE AS 名前 を付けると、その文で変更された全行を1つの表として参照できます(PostgreSQL 10以降)。1000行のINSERTでも関数は1回だけ呼ばれるので、集計や一括ログに向いています。

CREATE TABLE stock_moves (id serial PRIMARY KEY, item_id int, qty int);
CREATE TABLE stock_move_summary (moved_at timestamptz DEFAULT now(), op text, row_count int, total_qty int);

CREATE OR REPLACE FUNCTION summarize_moves() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO stock_move_summary (op, row_count, total_qty)
  SELECT TG_OP, count(*), COALESCE(sum(qty), 0) FROM new_rows;
  RETURN NULL;
END;
$$;

CREATE TRIGGER trg_stock_moves_summary
  AFTER INSERT ON stock_moves
  REFERENCING NEW TABLE AS new_rows
  FOR EACH STATEMENT
  EXECUTE FUNCTION summarize_moves();

INSERT INTO stock_moves (item_id, qty) SELECT g % 3, g FROM generate_series(1, 1000) g;
INSERT INTO stock_moves (item_id, qty) SELECT 1, 1 WHERE false;   -- 0行のINSERT
SELECT op, row_count, total_qty FROM stock_move_summary;

実行結果:

INSERT 0 1000
INSERT 0 0
   op   | row_count | total_qty 
--------+-----------+-----------
 INSERT |      1000 |    500500
 INSERT |         0 |         0
(2 rows)

1000行のINSERTで記録は1行だけ。そして、0行のINSERTでも文単位トリガーは動きます。「1行も変わらなければ何もしない」にしたい場合は、関数の先頭で IF NOT EXISTS (SELECT 1 FROM new_rows) THEN RETURN NULL; END IF; と書きます。

UPDATEでは REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows と両方を受け取り、主キーで結合して差分を集計できます(333行のUPDATEで NOTICE: 333 行更新、数量の増減合計 666 を確認)。TRUNCATEを禁止したいときも、BEFORE TRUNCATE ... FOR EACH STATEMENT で RAISE EXCEPTION する文単位トリガーにします。

遷移テーブルの制約(PostgreSQL 16で確認) エラー文
AFTER トリガーだけ transition table name can only be specified for an AFTER trigger
1つのトリガーに複数イベント(INSERT OR UPDATE)は不可 transition tables cannot be specified for triggers with more than one event
UPDATE OF 列 とは併用不可 (公式ドキュメントに記載)
パーティションの子テーブル・継承の子には行単位で付けられない (公式ドキュメントに記載)

テンプレ7:ビューに INSTEAD OF トリガーを付けて書き込めるようにする

結合や条件付きのビューにINSERT・DELETEしたいときは、INSTEAD OF トリガーで「実際にどのテーブルに何をするか」を書きます。テンプレ3の論理削除を、ビュー経由にした例です。

CREATE VIEW active_articles AS
  SELECT id, title FROM articles WHERE deleted_at IS NULL;

CREATE OR REPLACE FUNCTION active_articles_write() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    INSERT INTO articles (id, title) VALUES (NEW.id, NEW.title);
    RETURN NEW;
  ELSIF TG_OP = 'DELETE' THEN
    UPDATE articles SET deleted_at = now() WHERE id = OLD.id;
    RETURN OLD;
  END IF;
  RETURN NULL;
END;
$$;

CREATE TRIGGER trg_active_articles
  INSTEAD OF INSERT OR DELETE ON active_articles
  FOR EACH ROW
  EXECUTE FUNCTION active_articles_write();

INSERT INTO active_articles VALUES (3, '新着記事');
DELETE FROM active_articles WHERE id = 2;
SELECT id, title, deleted_at IS NOT NULL AS is_deleted FROM articles ORDER BY id;
SELECT * FROM active_articles;

実行結果(テンプレ3の続き。id=1 はテンプレ3で物理削除済み):

INSERT 0 1
DELETE 1
 id |  title   | is_deleted 
----+----------+------------
  2 | 更新情報 | t
  3 | 新着記事 | f
(2 rows)

 id |  title   
----+----------
  3 | 新着記事
(1 row)

ビューへのDELETEは DELETE 1 と返りつつ、実際は論理削除になっています。アプリはビューだけを見れば「削除済み」を意識せずに済みます。

同じタイミングのトリガーの実行順

同じテーブル・同じイベント・同じ種類のトリガーが複数あるときは、トリガー名のアルファベット順に実行されます。作成順ではありません。5つのトリガーを付けて確かめました。

CREATE TABLE t_order (id int, note text);
CREATE OR REPLACE FUNCTION say() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE NOTICE '% % % : %', TG_WHEN, TG_LEVEL, TG_OP, TG_NAME;
  IF TG_LEVEL = 'ROW' AND TG_WHEN = 'BEFORE' THEN
    NEW.note := coalesce(NEW.note, '') || '[' || TG_NAME || ']';
    RETURN NEW;
  END IF;
  RETURN NULL;
END; $$;
-- 作成順は b → a → z → c → y
CREATE TRIGGER b_row BEFORE INSERT ON t_order FOR EACH ROW EXECUTE FUNCTION say();
CREATE TRIGGER a_row BEFORE INSERT ON t_order FOR EACH ROW EXECUTE FUNCTION say();
CREATE TRIGGER z_after_row AFTER INSERT ON t_order FOR EACH ROW EXECUTE FUNCTION say();
CREATE TRIGGER c_stmt BEFORE INSERT ON t_order FOR EACH STATEMENT EXECUTE FUNCTION say();
CREATE TRIGGER y_after_stmt AFTER INSERT ON t_order FOR EACH STATEMENT EXECUTE FUNCTION say();
INSERT INTO t_order (id) VALUES (1);
SELECT * FROM t_order;

実行結果:

NOTICE:  BEFORE STATEMENT INSERT : c_stmt
NOTICE:  BEFORE ROW INSERT : a_row
NOTICE:  BEFORE ROW INSERT : b_row
NOTICE:  AFTER ROW INSERT : z_after_row
NOTICE:  AFTER STATEMENT INSERT : y_after_stmt
INSERT 0 1
 id |      note      
----+----------------
  1 | [a_row][b_row]
(1 row)
flowchart LR
  A[BEFORE STATEMENT] --> B[BEFORE ROW<br/>名前順・行ごと]
  B --> C[行の書き込み<br/>制約チェック]
  C --> D[AFTER ROW<br/>名前順]
  D --> E[AFTER STATEMENT]

順番は「BEFORE文 → BEFORE行(名前順)→ 書き込み → AFTER行 → AFTER文」です。BEFORE行トリガーが複数あると、前のトリガーが書き換えた NEW を次のトリガーが受け取ります([a_row][b_row] の順に追記されています)。順番に意味があるときは、t10_normalize、t20_validate のように番号を付けた名前にしておくと安全です。

トリガーの再帰(無限ループ)と止め方

トリガー関数の中で同じテーブルを更新すると、そのUPDATEでまたトリガーが動きます。下の例はAFTER UPDATEの中で自分のテーブルをUPDATEしています。

CREATE TABLE counters (id int PRIMARY KEY, n int);
INSERT INTO counters VALUES (1, 0);
CREATE OR REPLACE FUNCTION bump() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE NOTICE 'depth=%', pg_trigger_depth();
  UPDATE counters SET n = n + 1 WHERE id = NEW.id;
  RETURN NULL;
END; $$;
CREATE TRIGGER trg_bump AFTER UPDATE ON counters FOR EACH ROW EXECUTE FUNCTION bump();
UPDATE counters SET n = 1 WHERE id = 1;

実行結果(max_stack_depth = 2MB の既定値。途中を省略):

NOTICE:  depth=1
NOTICE:  depth=2
...
NOTICE:  depth=736
ERROR:  stack depth limit exceeded

736段まで入れ子になったところでエラーになり、UPDATE全体がロールバックされました(段数は環境によって変わります)。直し方は3つあります。

方法 書き方 向いている場面
BEFOREで NEW を書き換える NEW.n := NEW.n + 1; RETURN NEW; 同じ行の列を変えたいだけなら、これが最もシンプル
WHEN で深さを制限 WHEN (pg_trigger_depth() < 1) トリガーから呼ばれた更新では動かさない
WHEN で値の変化を見る WHEN (OLD.x IS DISTINCT FROM NEW.x) 2回目以降は値が変わらず自然に止まる

WHEN (pg_trigger_depth() < 1) を付けて作り直し、同じ UPDATE counters SET n = 1 を実行すると UPDATE 1 で n は 2 になりました(1にしたあと、トリガーが1回だけ +1)。ただし pg_trigger_depth() は「どのトリガーから呼ばれたか」を区別しないので、別テーブルのトリガー経由の更新も止まります。基本は1つ目の「BEFOREで書き換える」を選びます。

性能:1万件UPDATEでトリガーあり・なしを比べる

行トリガーは1行ごとに関数が呼ばれるので、件数に比例して時間が増えます。10,000行のテーブル4つに対して UPDATE ... SET qty = qty + 1(全件)を5回ずつ実行し、psql の \timing の中央値を比べました(PostgreSQL 16.15、共有のコンテナ環境なので値は目安です)。

4つのテーブルは同じ定義(id int PRIMARY KEY, qty int, updated_at timestamptz)で、付けたトリガーだけが違います。

テーブル トリガー 5回の計測(ms) 中央値 なしとの比
bench_plain なし 68 / 89 / 54 / 116 / 73 73 ms 1.0倍
bench_upd BEFORE ROW(updated_at) 92 / 111 / 81 / 119 / 109 109 ms 約1.5倍
bench_audit AFTER ROW(jsonbログを1行ずつINSERT) 150 / 223 / 208 / 277 / 211 211 ms 約2.9倍
bench_stmt AFTER STATEMENT+遷移テーブル(同じ件数のログを1文で) 129 / 115 / 143 / 143 / 183 143 ms 約2.0倍

EXPLAIN ANALYZE でもトリガーの呼び出し回数を確認できます。

 Update on bench_audit (actual rows=0 loops=1)
 Planning Time: 0.047 ms
 Trigger t2: calls=10000
 Execution Time: 314.299 ms

updated_at 程度の軽いトリガーなら1.5倍ほど、別テーブルへの1行ずつのINSERTを伴うと3倍ほどになりました。同じログを遷移テーブルで1文にまとめると、行トリガーより短くなっています。大量更新が多いテーブルの監査ログは、文単位トリガー+遷移テーブルを選ぶと効率的です。

PL/pgSQL の基本構文の早見表

やりたいこと 書き方
変数を宣言 DECLARE v_total int := 0; v_name text; r record;
列と同じ型で宣言 v_stock items.stock%TYPE; / 行全体は v_row items%ROWTYPE;
代入 v_total := v_total + 1;(= も可だが := が一般的)
SELECT結果を変数へ SELECT stock INTO v_stock FROM items WHERE id = 1;
1行でなければエラー SELECT ... INTO STRICT v_stock ...(0行で no_data_found、2行以上で too_many_rows)
直前のSQLで行があったか IF FOUND THEN ... END IF;
影響行数 GET DIAGNOSTICS v_cnt = ROW_COUNT;
条件分岐 IF 条件 THEN ... ELSIF 条件 THEN ... ELSE ... END IF;
CASE CASE WHEN 条件 THEN ... ELSE ... END CASE;
回数ループ FOR i IN 1..10 LOOP ... END LOOP;
クエリ結果のループ FOR r IN SELECT ... LOOP ... END LOOP;
条件ループ WHILE 条件 LOOP ... END LOOP; / LOOP ... EXIT WHEN 条件; END LOOP;
配列のループ FOREACH v IN ARRAY arr LOOP ... END LOOP;
次の周へ・抜ける CONTINUE WHEN 条件; / EXIT;
動的SQL EXECUTE format('UPDATE %I SET x = $1 WHERE id = $2', TG_TABLE_NAME) USING v_x, NEW.id;
メッセージ出力 RAISE NOTICE '値は %', v;(レベル:DEBUG / LOG / INFO / NOTICE / WARNING)
エラーで止める RAISE EXCEPTION 'メッセージ %', v USING ERRCODE = 'check_violation', HINT = '...';
例外を捕まえる BEGIN ... EXCEPTION WHEN 条件名 THEN ... END;
エラー詳細 SQLERRM、SQLSTATE、GET STACKED DIAGNOSTICS v_msg = MESSAGE_TEXT;

ループ・IF・例外処理を1つのDOブロックで動かした例です(テンプレ4の items が残っている状態)。

DO $$
DECLARE
  v_total int := 0;
  v_name  text;
  r       record;
  v_msg   text;
  v_state text;
BEGIN
  FOR i IN 1..3 LOOP
    v_total := v_total + i;
  END LOOP;
  RAISE NOTICE 'FOR合計=%', v_total;

  FOR r IN SELECT id, name, stock FROM items ORDER BY id LOOP
    IF r.stock = 0 THEN
      RAISE NOTICE '% は在庫切れ', r.name;
    ELSIF r.stock < 3 THEN
      RAISE NOTICE '% は残りわずか(%)', r.name, r.stock;
    ELSE
      RAISE NOTICE '% は在庫あり', r.name;
    END IF;
  END LOOP;

  SELECT name INTO STRICT v_name FROM items WHERE id = 999;
EXCEPTION
  WHEN no_data_found THEN
    GET STACKED DIAGNOSTICS v_msg = MESSAGE_TEXT, v_state = RETURNED_SQLSTATE;
    RAISE NOTICE '捕捉: % (SQLSTATE %)', v_msg, v_state;
END;
$$;

-- トリガーが投げたエラーを呼び出し側で捕まえる
DO $$
BEGIN
  INSERT INTO orders (item_id, qty) VALUES (10, 100);
EXCEPTION
  WHEN check_violation THEN
    RAISE NOTICE '注文を受け付けできませんでした: %', SQLERRM;
END;
$$;

実行結果:

NOTICE:  FOR合計=6
NOTICE:  ノート は残りわずか(2)
NOTICE:  捕捉: query returned no rows (SQLSTATE P0002)
DO
NOTICE:  注文を受け付けできませんでした: 在庫不足です: item_id=10, 在庫=2, 注文=100
DO

テンプレ4で付けた ERRCODE = 'check_violation' を、呼び出し側で WHEN check_violation として捕まえられています。なお EXCEPTION 句のあるブロックは内部でセーブポイントを作るため、1行ごとに呼ばれるトリガー関数では必要な箇所だけに使うと軽く保てます。

よくあるエラーと直し方(PostgreSQL 16の実際のエラー文)

エラー文 原因 直し方
function not_trigger must return type trigger 関数を RETURNS void などで作った RETURNS trigger で作り直す
control reached end of trigger procedure without RETURN 関数の最後に RETURN がない BEFORE行なら RETURN NEW;、それ以外は RETURN NULL;
trigger functions can only be called as triggers SELECT set_updated_at(); のように直接呼んだ トリガー経由でだけ呼ぶ。動作確認は実際にUPDATEする
DELETE trigger's WHEN condition cannot reference NEW values DELETEのWHENで NEW を参照 DELETEでは OLD を使う
TRUNCATE FOR EACH ROW triggers are not supported TRUNCATEを行単位で定義 FOR EACH STATEMENT にする
transition table name can only be specified for an AFTER trigger BEFOREに REFERENCING を付けた AFTERにする
transition tables cannot be specified for triggers with more than one event INSERT OR UPDATE に REFERENCING を付けた イベントごとにトリガーを分ける
stack depth limit exceeded トリガーが自分のテーブルを更新して再帰 前述の「再帰の止め方」
record "new" is not assigned yet PostgreSQL 10以前で、文単位トリガーやDELETEで NEW を参照 PostgreSQL 11以降はNULLになる。TG_OP で分岐して参照する

文単位トリガーで NEW.id を参照すると、PostgreSQL 16ではエラーにならず NULL になります(RAISE NOTICE '%', NEW.id; の出力は <NULL> でした)。エラーにならない分気づきにくいので、TG_LEVEL と TG_OP で分岐して書きます。

トリガーの確認・無効化・削除

やりたいこと コマンド
テーブルのトリガーを見る(psql) \d テーブル名(末尾に Triggers: が出る)
全トリガーを一覧 SELECT tgname, tgrelid::regclass, tgenabled FROM pg_trigger WHERE NOT tgisinternal;
定義を見る SELECT pg_get_triggerdef(oid) FROM pg_trigger WHERE tgname = 'trg_tasks_updated_at';
関数のソースを見る(psql) \sf set_updated_at
一時的に止める ALTER TABLE tasks DISABLE TRIGGER trg_tasks_updated_at;(ENABLE で戻す)
ユーザー定義トリガーを全部止める ALTER TABLE tasks DISABLE TRIGGER USER;
削除 DROP TRIGGER IF EXISTS trg_tasks_updated_at ON tasks;
関数ごと削除 DROP FUNCTION set_updated_at() CASCADE;(付いているトリガーも消える)

tgenabled の O は「通常どおり有効」、D は無効です。DISABLE TRIGGER ALL は外部キーの内部トリガーまで止めるため、データ移行などの限られた場面で、終わったら必ず ENABLE に戻します。

トリガーを使うべき場面・使わない方がよい場面

判断の軸は「データベースだけで完結し、どこから更新されても必ず守りたいルールか」です。

やりたいこと おすすめ 理由
updated_at の自動更新 トリガー どの経路の更新でも漏れない
変更履歴・監査ログ トリガー(大量更新なら文単位) アプリを経由しない更新も記録できる
1行で完結する値のチェック(0以上、範囲) CHECK制約 宣言だけで済み、速く、読みやすい
他テーブルとの存在チェック 外部キー 標準のしくみでロックも正しく扱われる
他の列から計算できる値 生成列(GENERATED ALWAYS AS (...) STORED) 関数不要で常に一致する
重複の禁止 UNIQUE制約・排他制約 トリガーでは同時実行時の取りこぼしが起きやすい
メール送信・外部APIの呼び出し アプリ側/ジョブキュー ロールバックしても送信は取り消せない。DBからは LISTEN/NOTIFY で知らせる程度に
複雑な業務ルール(割引計算など) アプリ側 テストしやすく、変更の履歴を追いやすい
集計テーブルの更新 件数次第。多ければマテリアライズドビューや定期集計 1行ごとの集計更新は同じ行のロック待ちが起きやすい

トリガーを入れたら、テーブル定義のコメント(COMMENT ON TRIGGER)やREADMEに「何が自動で起きるか」を書いておくと、後から読む人が挙動を追いやすくなります。

よくある質問

Q. BEFORE と AFTER はどう使い分けますか? A. これから書き込む行の値を変えたい・止めたいならBEFORE、確定した変更をもとに別のテーブルを更新するならAFTERです。AFTERの時点では制約チェックも済んでいるので、ログや集計はAFTERに置くと「実際には書き込まれなかった行」を記録せずに済みます。

Q. トリガーの中でエラーになったら、元のINSERTやUPDATEはどうなりますか? A. 文全体がロールバックされます。トリガー関数は呼び出し元と同じトランザクションで動くため、トリガーが書いたログも元の変更も、まとめて取り消されます(テンプレ4の実行結果で在庫・注文件数が変わっていないことを確認しています)。

Q. COPY や一括INSERTでもトリガーは動きますか? A. はい、COPY FROM でも INSERT トリガーは動きます。大量投入で時間がかかる場合は、行トリガーを文単位+遷移テーブルに変えるか、移行作業の間だけ ALTER TABLE ... DISABLE TRIGGER で止めて、後から一括で同じ処理を行う方法があります。

Q. パーティションテーブルにも付けられますか? A. 親のパーティションテーブルに行トリガーを作ると、すべての子パーティションに同じトリガーが自動で作られます(後から追加した子にも付きます)。ただし INSTEAD OF は付けられず、子テーブルに遷移テーブル付きの行トリガーは作れません。

まとめ

  • トリガー関数は RETURNS trigger の関数、トリガーは CREATE TRIGGER ... EXECUTE FUNCTION で別に作る
  • BEFORE ROW だけが RETURN NEW で書き換え、RETURN NULL でスキップできる。AFTERと文単位の戻り値は無視される
  • FOR EACH を省略すると文単位。行ごとに動かすなら FOR EACH ROW を書く
  • UPDATE OF 列 + WHEN (OLD.x IS DISTINCT FROM NEW.x) で、値が変わったときだけ動かせる
  • 同じ種類のトリガーは名前のアルファベット順。順番が大事なら番号付きの名前に
  • 自分のテーブルを更新すると再帰して stack depth limit exceeded。BEFOREでNEWを書き換えるのが基本
  • 1万件UPDATEで updated_atは約1.5倍、1行ずつのログは約2.9倍。大量更新の監査は文単位+遷移テーブルが効率的

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PostgreSQLトリガー関数の書き方|PL/pgSQLテンプレ集と早見表」

株式会社RJCでは、データベースの自動化のしくみを、動かして確かめながら仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?