- データベース
PostgreSQLトリガー関数の書き方|PL/pgSQLテンプレ集と早見表
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期連続の黒字決算。