ON DELETE CASCADE とは、親テーブルの行を削除したときに、その行を参照している子テーブルの行も自動で削除する外部キーの設定です。 ほかに、子の参照列をNULLにする SET NULL、削除そのものを止める RESTRICT / NO ACTION、既定値に置き換える SET DEFAULT があり、これらをまとめて「参照アクション」と呼びます。

この記事では、5つの参照アクションを PostgreSQL 16.15 と MySQL 8.0.46 の両方で同じSQLを実行 し、結果を比較表にまとめました。RESTRICT と NO ACTION の違い(DEFERRABLE)、ON UPDATE の動き、CASCADE の連鎖で想定より多く消える例、後から制約を付けるときのエラーと探し方、参照列にインデックスがないときの DELETE の遅さ(11.6秒→3.9ミリ秒)まで、実行結果つきで解説します。

参照アクションの早見表

参照アクション 親を削除したとき子は 親のキーを更新したとき子は PostgreSQL 16 MySQL 8.0(InnoDB) 向いているデータ
CASCADE 一緒に削除 新しいキーに更新 対応 対応 注文と注文明細など「親と一体」の子
SET NULL 参照列がNULLに 参照列がNULLに 対応(15以降は列指定も可) 対応 タスクの担当者など「いなくても残す」子
SET DEFAULT 参照列が既定値に 参照列が既定値に 対応 使えない(削除がエラー) 「未分類」などの代わりの親がある子
RESTRICT 削除をエラーで止める 更新をエラーで止める 対応(即時チェック) 対応 商品マスタ・カテゴリなど参照されるマスタ
NO ACTION(既定) 削除をエラーで止める 更新をエラーで止める 対応(遅延チェック可) RESTRICTと同じ 指定しない場合の既定
やりたいこと コピペ用SQL(PostgreSQL / MySQL共通の部分)
作成時に付ける FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
後から付ける ALTER TABLE tasks ADD CONSTRAINT tasks_staff_fk FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE SET NULL;
参照先がない行を探す SELECT t.* FROM tasks t LEFT JOIN staff s ON s.id = t.staff_id WHERE t.staff_id IS NOT NULL AND s.id IS NULL;
参照アクションを変える 制約を削除して付け直す(PostgreSQLは DROP CONSTRAINT、MySQLは DROP FOREIGN KEY)
既存データを後回しで付ける(PG) ... NOT VALID; → 直したあと ALTER TABLE tasks VALIDATE CONSTRAINT tasks_staff_fk;
一時的にチェックを止める(MySQL) SET FOREIGN_KEY_CHECKS = 0; … SET FOREIGN_KEY_CHECKS = 1;
参照列にインデックスを作る(PG) CREATE INDEX order_lines_order_id_idx ON order_lines(order_id);

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

(記載例)出典:株式会社RJC「外部キーON DELETE CASCADE|SET NULL・RESTRICT比較」

5つの参照アクションをPostgreSQLとMySQLで実行した結果

次のテーブルで、参照アクション($act)だけを入れ替えて実行しました。親 p には「未設定」を表す id=0 の行も用意しています。

DROP TABLE IF EXISTS c, p;
CREATE TABLE p (id int PRIMARY KEY, name varchar(20));
CREATE TABLE c (id int PRIMARY KEY, p_id int DEFAULT 0,
  FOREIGN KEY (p_id) REFERENCES p(id) ON DELETE $act ON UPDATE $act);
INSERT INTO p VALUES (0,'未設定'), (1,'親1'), (2,'親2');
INSERT INTO c VALUES (10,1), (11,1), (20,2);

DELETE FROM p WHERE id = 1;            -- ① 親1を削除
SELECT * FROM c ORDER BY id;
UPDATE p SET id = 200 WHERE id = 2;    -- ② 親2のキーを200に更新
SELECT * FROM c ORDER BY id;

比較表(実行結果)

アクション ① DELETE後の子 c(PG16) ① DELETE後の子 c(MySQL 8.0) ② UPDATE後の子 c(PG16) ② UPDATE後の子 c(MySQL 8.0)
CASCADE (20,2) のみ。10と11は削除 同じ (20,200) 同じ
SET NULL (10,NULL) (11,NULL) (20,2) 同じ 20もNULLに 同じ
SET DEFAULT (10,0) (11,0) (20,2) ERROR 1451、子は変化なし 20も0に ERROR 1451
RESTRICT ERROR、子は変化なし ERROR 1451 ERROR ERROR 1451
NO ACTION ERROR、子は変化なし ERROR 1451 ERROR ERROR 1451

PostgreSQLのRESTRICT / NO ACTIONのエラー:

ERROR:  update or delete on table "p" violates foreign key constraint "c_p_id_fkey" on table "c"
DETAIL:  Key (id)=(1) is still referenced from table "c".

MySQLのRESTRICTのエラー:

ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails
(`t107`.`c`, CONSTRAINT `c_ibfk_1` FOREIGN KEY (`p_id`) REFERENCES `p` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT)

CASCADE・SET NULL・RESTRICT・NO ACTION は、2つのDBで同じ結果になりました。 違いが出たのは SET DEFAULT です。

MySQLのSET DEFAULTは「定義は通るが削除できない」

MySQLの公式ドキュメントでは、SET DEFAULT は「パーサーは認識するが、InnoDB と NDB は ON DELETE SET DEFAULT / ON UPDATE SET DEFAULT を含むテーブル定義を拒否する」と説明されています。ところが今回の MySQL 8.0.46 では、CREATE TABLE は警告なしで成功し、親を削除した時点で ERROR 1451 になりました。

mysql> SHOW CREATE TABLE c\G
  CONSTRAINT `c_ibfk_1` FOREIGN KEY (`p_id`) REFERENCES `p` (`id`) ON DELETE SET DEFAULT
mysql> DELETE FROM p WHERE id = 1;
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`t107`.`c`, CONSTRAINT `c_ibfk_1` FOREIGN KEY (`p_id`) REFERENCES `p` (`id`))

どちらにしても、MySQLでは SET DEFAULT の動きは得られません。 「削除されたら未分類(id=0)に付け替える」ことをしたいときは、アプリ側で先に UPDATE c SET p_id = 0 WHERE p_id = 1; を実行してから親を削除します。

PostgreSQLのSET DEFAULTは既定値の親が必要

PostgreSQLでは SET DEFAULT が動きますが、既定値(ここでは0)の行が親にないと、付け替え先がないためエラーになります。

-- 親に id=0 の行がない状態で DELETE
ERROR:  insert or update on table "c" violates foreign key constraint "c_p_id_fkey"
DETAIL:  Key (p_id)=(0) is not present in table "p".

SET NULLとNOT NULL列

DB 参照列が NOT NULL のまま ON DELETE SET NULL を付けると
MySQL 8.0 作成時にエラー:ERROR 1830 (HY000): Column 'staff_id' cannot be NOT NULL: needed in a foreign key constraint 'x_ibfk_1' SET NULL
PostgreSQL 16 作成はできるが、親を削除した時点で null value in column ... violates not-null constraint

SET NULL を使う列は、NULLを許可しておきます。

RESTRICTとNO ACTIONの違い(DEFERRABLEで確認)

PostgreSQLでは、RESTRICT は「すぐにチェック」、NO ACTION は「チェックをトランザクションの最後まで遅らせられる(DEFERRABLE)」という違いがあります。 遅らせない通常の状態では、どちらも同じエラーになります。

同じトランザクションの中で「親を削除して、同じキーで作り直す」操作で比べました。

CREATE TABLE p (id int PRIMARY KEY, name text);
CREATE TABLE c (id int PRIMARY KEY,
  p_id int REFERENCES p(id) ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED);  -- RESTRICT と入れ替えて比較
INSERT INTO p VALUES (1,'親1'), (2,'親2');
INSERT INTO c VALUES (10,1), (20,2);

BEGIN;
DELETE FROM p WHERE id = 1;
INSERT INTO p VALUES (1,'親1(作り直し)');
COMMIT;
設定 結果(PostgreSQL 16)
ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED DELETE 1 → COMMIT 成功。子10は作り直した親1を参照
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED DELETEの時点で ERROR(DEFERRABLEを付けても即時チェック)
-- RESTRICT の場合
DELETE FROM p WHERE id = 1;
ERROR:  update or delete on table "p" violates foreign key constraint "c_p_id_fkey" on table "c"
DETAIL:  Key (id)=(1) is still referenced from table "c".

NO ACTION で遅らせたまま参照が切れていれば、COMMITの時点でエラーになり、全体が取り消されます。

BEGIN;
SET CONSTRAINTS ALL DEFERRED;
DELETE FROM p WHERE id = 1;
SELECT 'まだエラーにならない' AS msg;   -- ここまでは通る
COMMIT;
ERROR:  update or delete on table "p" violates foreign key constraint "c_p_id_fkey" on table "c"
DETAIL:  Key (id)=(1) is still referenced from table "c".
比べる点 RESTRICT NO ACTION
PostgreSQLのチェックのタイミング 即時(遅延不可) 文の終わり。DEFERRABLEならCOMMIT時まで遅延可
MySQL(InnoDB) 即時 RESTRICTと同じ(InnoDBは遅延チェック非対応)
指定しないとき - これが既定
選び方 「どんな手順でも参照中の親は消させない」ことを明示したいとき 親の作り直しや入れ替えを1トランザクションで行うことがあるとき

ON UPDATEの動きと使いどころ

ON UPDATE は、親の主キーの値が変わったときの子の扱いです。比較表の②のとおり、CASCADE なら子の参照列も新しい値に追従します。

ON UPDATE 使いどころ
CASCADE 商品コードや部署コードなど、意味のあるコードを主キーにしていて、変更があり得るとき
NO ACTION / RESTRICT(既定) 主キーが連番やUUIDで、変わらないとき(多くのテーブル)
SET NULL 親のキー変更で関係を切りたいとき(使う場面は少なめ)

主キーを連番・IDENTITY・UUIDなど変わらない値にしておけば、ON UPDATE は既定のままで困りません。 なお、PostgreSQLの SET NULL (列名) の列指定は ON DELETE 専用です(後述)。

データの種類別の選び方

親 → 子 おすすめ 理由
注文 → 注文明細 ON DELETE CASCADE 明細は注文がないと意味を持たない。注文の削除で一緒に消えてよい
記事 → コメント・タグ付け ON DELETE CASCADE 記事と一体のデータ
担当者 → タスク ON DELETE SET NULL 担当者が退職・削除されてもタスクは残し、「担当者なし」にする
作成者 → 備品の貸出記録 SET NULL または論理削除 記録は残したい。作成者を消さずに無効フラグで扱う方法も
カテゴリ・商品マスタ → 商品・明細 ON DELETE RESTRICT(またはNO ACTION) マスタは参照されている間は消せないほうが安全。不要になったら無効フラグで扱う
会員 → 会員の設定(1対1) ON DELETE CASCADE 会員と一体
-- 例:用途別の付け方(PostgreSQL / MySQL 共通で書ける形)
CREATE TABLE order_items (
  order_id   int NOT NULL,
  product_id int NOT NULL,
  qty int NOT NULL,
  PRIMARY KEY (order_id, product_id),
  FOREIGN KEY (order_id)   REFERENCES orders(id)   ON DELETE CASCADE,
  FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT
);
CREATE TABLE tasks (
  id int PRIMARY KEY,
  title varchar(100) NOT NULL,
  staff_id int NULL,
  FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE SET NULL
);

判断の目安は「親が消えたとき、子だけ残っても意味があるか」です。 意味がないなら CASCADE、残したいなら SET NULL、親を消すこと自体を止めたいなら RESTRICT を選びます。

CASCADEの連鎖で想定より多く消える例

CASCADE は連鎖します。 カテゴリ → 商品 → 注文明細 のすべてに CASCADE を付けると、カテゴリを1行消しただけで、過去の注文明細まで消えます。

CREATE TABLE categories (id int PRIMARY KEY, name text);
CREATE TABLE products   (id int PRIMARY KEY, name text,
  category_id int NOT NULL REFERENCES categories(id) ON DELETE CASCADE);
CREATE TABLE orders     (id int PRIMARY KEY, ordered_on date);
CREATE TABLE order_items(order_id int REFERENCES orders(id) ON DELETE CASCADE,
  product_id int REFERENCES products(id) ON DELETE CASCADE, qty int,
  PRIMARY KEY (order_id, product_id));
INSERT INTO categories VALUES (1,'文具'), (2,'家具');
INSERT INTO products VALUES (101,'ボールペン',1), (102,'ノート',1), (201,'デスク',2);
INSERT INTO orders VALUES (1,'2026-09-01'), (2,'2026-09-02');
INSERT INTO order_items VALUES (1,101,3), (1,201,1), (2,102,5);

DELETE FROM categories WHERE id = 1;
SELECT (SELECT count(*) FROM products) AS products, (SELECT count(*) FROM order_items) AS order_items;
-- 削除前
 products | order_items
----------+-------------
        3 |           3
DELETE 1
-- 削除後
 products | order_items
----------+-------------
        1 |           1

DELETE 1 と表示されても、実際には商品2行と注文明細2行も消えています(MySQL 8.0でも同じ結果で、ROW_COUNT() は1でした)。注文2は明細が0行の注文になりました。

直し方:マスタからの参照(products → categories、order_items → products)は RESTRICT にします。 CASCADE は「親と一体の子」(orders → order_items)だけに付けると、消える範囲が予想しやすくなります。

CASCADEの連鎖を事前に調べる(PostgreSQL)

外部キーと参照アクションの一覧:

SELECT conrelid::regclass AS child, confrelid::regclass AS parent, conname,
       CASE confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE'
                        WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS on_delete,
       CASE confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE'
                        WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS on_update
FROM pg_constraint
WHERE contype = 'f' AND connamespace = 'public'::regnamespace
ORDER BY 1, 3;
    child    |   parent   |           conname           | on_delete | on_update
-------------+------------+-----------------------------+-----------+-----------
 products    | categories | products_category_id_fkey   | CASCADE   | NO ACTION
 order_items | orders     | order_items_order_id_fkey   | CASCADE   | NO ACTION
 order_items | products   | order_items_product_id_fkey | CASCADE   | NO ACTION

あるテーブルを消したときに CASCADE でたどられるテーブルを、再帰クエリで洗い出せます。

WITH RECURSIVE chain AS (
  SELECT conrelid::regclass AS child, confrelid::regclass AS parent, 1 AS depth
  FROM pg_constraint
  WHERE contype = 'f' AND confdeltype = 'c' AND confrelid = 'categories'::regclass
  UNION ALL
  SELECT c.conrelid::regclass, c.confrelid::regclass, chain.depth + 1
  FROM pg_constraint c JOIN chain ON c.confrelid = chain.child::oid
  WHERE c.contype = 'f' AND c.confdeltype = 'c'
)
SELECT * FROM chain;
    child    |   parent   | depth
-------------+------------+-------
 products    | categories |     1
 order_items | products   |     2

MySQLでは information_schema.REFERENTIAL_CONSTRAINTS の DELETE_RULE / UPDATE_RULE で同じ一覧を確認できます。

SELECT TABLE_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME, DELETE_RULE, UPDATE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() ORDER BY 1, 2;

外部キーを後から付ける・変える・一時的に止める

後から付けたらエラーになったとき(既存データの不整合)

参照先がない値(孤児データ)が1行でもあると、制約を付けられません。

CREATE TABLE staff2 (id int PRIMARY KEY, name varchar(20));
CREATE TABLE tasks2 (id int PRIMARY KEY, title varchar(20), staff_id int);
INSERT INTO staff2 VALUES (1,'佐藤'), (2,'鈴木');
INSERT INTO tasks2 VALUES (1,'棚卸し',1), (2,'発注',2), (3,'検品',9), (4,'清掃',NULL);
ALTER TABLE tasks2 ADD CONSTRAINT tasks2_staff_fk
  FOREIGN KEY (staff_id) REFERENCES staff2(id) ON DELETE SET NULL;
-- PostgreSQL 16
ERROR:  insert or update on table "tasks2" violates foreign key constraint "tasks2_staff_fk"
DETAIL:  Key (staff_id)=(9) is not present in table "staff2".

-- MySQL 8.0
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
(`t107`.`#sql-2461_10`, CONSTRAINT `tasks2_staff_fk` FOREIGN KEY (`staff_id`) REFERENCES `staff2` (`id`) ON DELETE SET NULL)

原因の行は LEFT JOIN で探します(PostgreSQL・MySQL共通)。

SELECT t.* FROM tasks2 t LEFT JOIN staff2 s ON s.id = t.staff_id
WHERE t.staff_id IS NOT NULL AND s.id IS NULL;
 id | title | staff_id
----+-------+----------
  3 | 検品  |        9

見つかった行は、正しい親に直す・NULLにする・削除する、のいずれかで整えてから制約を付けます。

PostgreSQL:NOT VALIDで先に付けて、あとで検証する

大きなテーブルや、既存データの修正に時間がかかるときは NOT VALID が便利です。新しく入る行だけをすぐチェックし始め、既存行の検証は後回しにできます。

t107=> ALTER TABLE tasks2 ADD CONSTRAINT tasks2_staff_fk
         FOREIGN KEY (staff_id) REFERENCES staff2(id) ON DELETE SET NULL NOT VALID;
ALTER TABLE
t107=> INSERT INTO tasks2 VALUES (5,'梱包',8);            -- 新しい行はチェックされる
ERROR:  insert or update on table "tasks2" violates foreign key constraint "tasks2_staff_fk"
DETAIL:  Key (staff_id)=(8) is not present in table "staff2".
t107=> ALTER TABLE tasks2 VALIDATE CONSTRAINT tasks2_staff_fk;   -- 既存の孤児が残っている
ERROR:  insert or update on table "tasks2" violates foreign key constraint "tasks2_staff_fk"
DETAIL:  Key (staff_id)=(9) is not present in table "staff2".
t107=> UPDATE tasks2 SET staff_id = NULL WHERE id = 3;
UPDATE 1
t107=> ALTER TABLE tasks2 VALIDATE CONSTRAINT tasks2_staff_fk;
ALTER TABLE

参照アクションを変える

参照アクションだけを変える構文はないため、制約を削除して付け直します。

-- PostgreSQL(トランザクションでまとめる)
BEGIN;
ALTER TABLE tasks2 DROP CONSTRAINT tasks2_staff_fk;
ALTER TABLE tasks2 ADD CONSTRAINT tasks2_staff_fk
  FOREIGN KEY (staff_id) REFERENCES staff2(id) ON DELETE RESTRICT;
COMMIT;
-- MySQL(2文に分ける)
ALTER TABLE tasks2 DROP FOREIGN KEY tasks2_staff_fk;
ALTER TABLE tasks2 ADD CONSTRAINT tasks2_staff_fk
  FOREIGN KEY (staff_id) REFERENCES staff2(id) ON DELETE RESTRICT;

MySQLで DROP FOREIGN KEY と ADD CONSTRAINT を同じ名前で1つのALTER文にまとめると、次のエラーになりました。

ERROR 1826 (HY000): Duplicate foreign key constraint name 'tasks2_staff_fk'

一時的にチェックを止める

DB 方法 必要な権限・注意
MySQL SET FOREIGN_KEY_CHECKS = 0; … = 1; セッション単位。戻してもそれまでに入った行は再チェックされない
PostgreSQL SET session_replication_role = replica; スーパーユーザー相当が必要。外部キーを含むトリガーが止まる
PostgreSQL ALTER TABLE t DISABLE TRIGGER ALL; スーパーユーザーが必要(一般ユーザーは permission denied: "RI_ConstraintTrigger_c_..." is a system trigger)

MySQLで FOREIGN_KEY_CHECKS = 0 にすると、孤児データがあっても制約を追加できてしまいます。

mysql> SET FOREIGN_KEY_CHECKS = 0;
mysql> ALTER TABLE tasks2 ADD CONSTRAINT tasks2_staff_fk FOREIGN KEY (staff_id) REFERENCES staff2(id) ON DELETE SET NULL;
Query OK, 0 rows affected (0.03 sec)
mysql> SET FOREIGN_KEY_CHECKS = 1;

このあと同じ制約を付け直そうとすると、残っていた孤児(id=3、staff_id=9)のために ERROR 1452 になりました。PostgreSQLでも session_replication_role = replica の間に入れた行(staff_id=99)が、LEFT JOINの孤児チェックで見つかりました。チェックを止めたあとは、上の孤児チェックSQLで確認するのが確実です。データ移行など限られた場面で使い、通常は止めずに運用します。

参照列のインデックスとDELETEの速さ(実測)

PostgreSQLは、子テーブルの参照列(例:order_lines.order_id)にインデックスを自動では作りません。 一方 MySQL(InnoDB)は、参照列にインデックスがなければ自動で作ります(SHOW INDEX で tasks2_staff_fk という名前のインデックスが作られていることを確認)。

親を削除するとき、PostgreSQLは「この親を参照している子があるか」を子テーブルで探します。インデックスがないと、そのたびに子テーブル全体を読みます。

-- PostgreSQL 16.15:注文10万件、明細100万件(各注文に10行)
CREATE TABLE sales_orders (id int PRIMARY KEY, ordered_on date);
CREATE TABLE order_lines  (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id int NOT NULL REFERENCES sales_orders(id) ON DELETE CASCADE, qty int);
INSERT INTO sales_orders SELECT g, DATE '2026-01-01' + (g % 365) FROM generate_series(1, 100000) g;
INSERT INTO order_lines(order_id, qty) SELECT (g % 100000) + 1, g % 9 + 1 FROM generate_series(1, 1000000) g;
ANALYZE sales_orders; ANALYZE order_lines;

\timing on
BEGIN; DELETE FROM sales_orders WHERE id BETWEEN 1 AND 200; ROLLBACK;     -- インデックスなし
CREATE INDEX order_lines_order_id_idx ON order_lines(order_id);
BEGIN; DELETE FROM sales_orders WHERE id BETWEEN 1 AND 200; ROLLBACK;     -- インデックスあり
BEGIN; DELETE FROM sales_orders WHERE id BETWEEN 1 AND 20000; ROLLBACK;   -- インデックスあり・2万件
DELETE 200
Time: 11579.240 ms (00:11.579)      ← インデックスなし
CREATE INDEX
Time: 363.742 ms
DELETE 200
Time: 3.918 ms                       ← インデックスあり
DELETE 20000
Time: 305.400 ms                     ← インデックスあり・2万件(明細20万行を連鎖削除)
条件(注文200件を削除、明細2,000行が連鎖削除) 時間
参照列のインデックスなし 11.58秒
参照列のインデックスあり 3.9ミリ秒(約3,000倍速い)

外部キーを付けたら、子テーブルの参照列にもインデックスを作ると覚えておくと安心です。CASCADE に限らず、RESTRICT / NO ACTION でも親の削除・キー更新のたびに同じ検索が走ります。インデックスの種類や効かないパターンは別記事で詳しく扱っています。

PostgreSQLのSET NULL(列指定)

PostgreSQL 15 から、複合外部キーの一部の列だけを NULL(または既定値)にする ON DELETE SET NULL (列名) が使えます(PostgreSQL 15 のリリースノートと 17 のドキュメントで確認。以下は16で実行)。店舗ごとのカテゴリのように、複合キーの片方(shop_id)は残したいときに使います。

CREATE TABLE shops (id int PRIMARY KEY);
CREATE TABLE shop_categories (shop_id int REFERENCES shops(id), id int, name text,
  PRIMARY KEY (shop_id, id));
CREATE TABLE shop_products (id int PRIMARY KEY, shop_id int NOT NULL, category_id int, name text,
  FOREIGN KEY (shop_id, category_id) REFERENCES shop_categories(shop_id, id)
    ON DELETE SET NULL (category_id));
INSERT INTO shops VALUES (1);
INSERT INTO shop_categories VALUES (1, 10, '文具');
INSERT INTO shop_products VALUES (1, 1, 10, 'ボールペン');
DELETE FROM shop_categories WHERE shop_id = 1 AND id = 10;
SELECT * FROM shop_products;
 id | shop_id | category_id |    name
----+---------+-------------+------------
  1 |       1 |             | ボールペン

列指定なしの ON DELETE SET NULL だと、shop_id まで NULL にしようとしてエラーになります(同じ構成で列指定を外したテーブル x_all で確認)。

ERROR:  null value in column "shop_id" of relation "x_all" violates not-null constraint
DETAIL:  Failing row contains (1, null, null).

列指定は ON DELETE 専用で、ON UPDATE に書くとエラーです。

ERROR:  a column list with SET NULL is only supported for ON DELETE actions

よくある質問

Q. ON DELETE を何も書かないとどうなりますか? A. NO ACTION(既定)になり、参照されている親は削除できません。PostgreSQL・MySQLとも、実行結果は RESTRICT と同じエラーでした。

Q. CASCADE で消えた子の行数を知る方法はありますか? A. DELETE の結果は親の行数だけを返します。事前に子の件数を SELECT count(*) で数えるか、PostgreSQLなら上の再帰クエリで連鎖するテーブルを確認してから実行すると安心です。

Q. 論理削除(deleted_at)を使う場合、ON DELETE はどう決めますか? A. 論理削除は UPDATE なので、参照アクションは動きません。親を物理削除しない運用なら、外部キーは RESTRICT(既定のNO ACTION)にしておき、誤って物理削除されるのを防ぐ使い方が合います。

Q. SQLiteでも同じですか? A. SQLiteも同じ5種類の参照アクションを持ちますが、外部キーのチェックは接続ごとに PRAGMA foreign_keys = ON; で有効にする必要があります(この記事では未検証)。

まとめ

  • CASCADE は子も削除、SET NULL は参照列をNULL、RESTRICT / NO ACTION は削除を止める
  • PostgreSQL 16 と MySQL 8.0 で、CASCADE・SET NULL・RESTRICT・NO ACTION は同じ結果
  • SET DEFAULT は PostgreSQL のみ。 MySQL 8.0.46 では定義は通るが削除時に ERROR 1451
  • RESTRICT は即時、NO ACTION は DEFERRABLE で COMMIT 時まで遅らせられる(PostgreSQL)
  • CASCADE は「親と一体の子」だけに。 マスタからの参照は RESTRICT にすると連鎖削除を防げる
  • 後から付けてエラーなら LEFT JOIN で孤児を探す。 PostgreSQLは NOT VALID → VALIDATE も使える
  • PostgreSQLは参照列のインデックスを自分で作る。 なしで11.6秒、ありで3.9ミリ秒だった

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

(記載例)出典:株式会社RJC「外部キーON DELETE CASCADE|SET NULL・RESTRICT比較」

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