外部キー(FOREIGN KEY)は、ある表の列が別の表の主キーを指していることをデータベースに宣言し、存在しない相手への参照を書き込ませない制約です。注文が架空の顧客を指す、削除済みの商品を明細が参照し続ける、といった壊れ方をDB自身が止めてくれます。この記事では、外部キー制約の定義と参照先の条件、PostgreSQLとSQLiteで違反エラーを確かめるSQL、ON DELETEの5つの参照動作の選び方、稼働中のテーブルへ後から追加する2段階の手順、そしてDBで張るかアプリで持つかの判断基準までを、実装の単位で整理します。
まとめ:外部キーはDB側で張るのを既定にし、張らない条件を先に決める
結論から書きます。複数のプログラムが同じテーブルへ書き込む業務システムでは、外部キーはDB側で張るのを既定にしてください。アプリのコードで参照先の存在を確かめる方式は、別のバッチや手作業のSQLが1本でも書き込んだ時点で保証が切れます。
そのうえで、決めるべきことは3つあります。1つ目は親を消したときに子をどうするかで、明細のように親と寿命が同じデータだけをCASCADEにし、顧客や商品のようなマスタを参照する列はRESTRICTか既定のNO ACTIONで止めます。2つ目は子側の外部キー列に索引を張ることです。PostgreSQLとSQLiteは自動では作りません。3つ目は既存テーブルへの後付けで、PostgreSQLならNOT VALIDで追加してからVALIDATE CONSTRAINTで検証する2段階にすると、書き込みを長く止めずに済みます。
張らない選択が正しいのは、DBをまたぐ参照のように制約で表せない場合と、分散DBで検証コストが合わない場合だけです。
外部キー制約の定義と、参照先に主キーや一意性が求められる理由
親テーブルと子テーブルの関係と外部キーが拒否する4種類の操作
参照される側を親テーブル、参照する側を子テーブルと呼びます。注文テーブルのcustomer_idが顧客テーブルのidを指すなら、顧客が親、注文が子です。主キー・外部キー・候補キーの役割の違いは属性とキー・制約の種類を整理した記事で扱っているので、ここでは制約が実際に何を止めるかに絞ります。
外部キー制約が拒否する操作は4つです。子へのINSERTで親に無い値を入れる、子の外部キー列を親に無い値へUPDATEする、子から参照されている親行をDELETEする、参照されている親の主キーをUPDATEする。前の2つは常にエラーになり、後の2つはON DELETEとON UPDATEの指定で挙動が変わります。
参照先に主キー以外の一意制約を選べる条件とMySQL 8.4系の扱い
参照先は主キーでなくても構いません。PostgreSQL 18の制約の章は、参照先を「主キー、一意制約、または部分インデックスではない一意インデックスの列」と定めています。社員番号のような業務上の一意キーを、サロゲートキーとは別に参照させたい場合に使えます。
MySQLのInnoDBは歴史的に、一意でない索引の列も参照先にできる独自拡張を持っていました。MySQL 8.4系のリファレンスマニュアルはこの使い方を非推奨とし、restrict_fk_on_non_standard_keyの設定で明示的に許可しないと使えない扱いに変えています。将来の版で削除される予定と書かれているため、既存スキーマにこの形が残っていれば、移行計画の中で一意キーへ張り替えてください。
複合外部キーとNULLの扱いを決めるMATCH SIMPLEとMATCH FULLの差
外部キー列にNULLが入っている行は、既定では制約の検査を受けません。注文の担当者が未定ならassignee_idをNULLにしておける、という意味では便利な仕様です。
落とし穴は複数列の外部キーです。(tenant_id, author_id)の2列で親を指すとき、既定の動作では片方だけがNULLでも検査を素通りします。PostgreSQLのドキュメントは、MATCH FULLを付けると「全列がNULLの場合だけ」検査を免れ、NULLと非NULLの混在は必ず失敗すると説明しています。NULLを一切許さないなら、MATCH FULLより先に外部キー列へNOT NULLを付けるほうが意図が明確です。
PostgreSQLとSQLiteで外部キーを張り違反エラーを確かめる手順
CREATE TABLEで顧客と注文と明細の外部キーを宣言する最小のSQL
以下はPostgreSQL向けの定義です。注文は顧客をRESTRICTで参照し、明細は注文をCASCADEで参照します。
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL
REFERENCES customers (id) ON DELETE RESTRICT,
ordered_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
CREATE TABLE order_items (
order_id bigint NOT NULL
REFERENCES orders (id) ON DELETE CASCADE,
line_no int NOT NULL,
sku text NOT NULL,
qty int NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, line_no)
);
-- 存在しない顧客を指す注文は拒否される
INSERT INTO orders (customer_id) VALUES (999);
-- ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey"
-- DETAIL: Key (customer_id)=(999) is not present in table "customers".
制約名を省くと、PostgreSQLは表名_列名_fkeyの形で付けるのが自動命名の方式です。後でALTER TABLE ... DROP CONSTRAINTやVALIDATE CONSTRAINTで名前を指定するため、本番のマイグレーションではCONSTRAINT orders_customer_fk FOREIGN KEY ...のように明示して付けておくと運用が楽になります。ER図のリレーション線をこの宣言へ落とす対応関係は、ER図の書き方とカーディナリティを解説した記事で図と並べて確認できます。
SQLiteは接続ごとにPRAGMA foreign_keysを有効化しないと素通りする
SQLiteでは、外部キーを宣言しただけでは何も検査されません。SQLite公式のForeign Key Supportは、後方互換のため既定で無効であり、データベース接続ごとにPRAGMA foreign_keys = ONで有効にする必要があると明記しています。
PRAGMA foreign_keys; -- 0(無効)が返る
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers (id)
);
INSERT INTO orders (customer_id) VALUES (999); -- 無効のままなので通ってしまう
PRAGMA foreign_keys = ON;
INSERT INTO orders (customer_id) VALUES (998);
-- FOREIGN KEY constraint failed
SQLite 3.50系でこのSQLを順に実行すると、1回目の挿入は成功し、有効化後の2回目だけがFOREIGN KEY constraint failedで拒否されます。無効の間に入った999の行は、有効化した後も残り続けます。もう1つの注意点は、複数文のトランザクションの途中でPRAGMA foreign_keysを切り替えても、エラーにならず単に無視されることです。ORMやコネクションプールを使う場合は、接続を開いた直後のフックで毎回有効化してください。
子テーブルの外部キー列に索引を自分で張るDBと自動で張るDB
親の行を削除したり主キーを更新したりすると、DBは子テーブルに参照行が残っていないかを探します。子の外部キー列に索引が無ければ、この確認は子テーブルの全件走査になり、親1行の削除が遅くなる原因の多くはここにあります。
| DB | 子の外部キー列の索引 | 根拠 |
|---|---|---|
| PostgreSQL 18系 | 自動では作らない | 制約の章に「参照列の索引は自動作成しない」と記載 |
| MySQL 8.4系(InnoDB) | 無ければ自動で作る | 外部キー列を先頭に持つ索引が必須で、無い場合は作成 |
| SQLite 3系 | 自動では作らない | 必須ではないが、実運用ではほぼ常に張るべきと記載 |
先ほどのPostgreSQLの例でordersにだけCREATE INDEXを書き、order_itemsに書かなかったのは、主キー(order_id, line_no)の先頭列がorder_idで、その索引が外部キーの検索にも使えるからです。複合主キーの2列目以降を外部キーにしている場合は、別に索引が必要になります。
ON DELETEの5つの参照動作を業務データの種類で選び分ける基準
参照動作は「親を消したら子をどうするか」の宣言です。データの寿命と、消えたときに誰が困るかで選びます。
| 参照動作 | 親を削除したときの子 | 向くデータ | DBごとの差 |
|---|---|---|---|
| NO ACTION(既定) | 参照が残れば削除を拒否 | 迷ったときの既定値 | PostgreSQLでは検査を遅延可 |
| RESTRICT | 参照が残れば即座に拒否 | 顧客・商品などのマスタ参照 | PostgreSQLでは遅延不可 |
| CASCADE | 子も一緒に削除 | 注文明細・添付ファイル行 | InnoDBはカスケードでトリガーが動かない |
| SET NULL | 子の外部キー列をNULLに | 担当者・作成者などの任意参照 | PostgreSQL 15以降は列を限定可 |
| SET DEFAULT | 子の外部キー列を既定値に | 「未分類」等の代替行がある場合 | InnoDBは定義自体を拒否 |
NO ACTIONとRESTRICTの違いはトランザクション末尾まで検査を遅らせるか
どちらも「参照が残っていれば親の削除を拒否する」点は同じです。両者で異なるのは、検査を実行するタイミングです。PostgreSQLのCREATE TABLEの説明によると、DEFERRABLE INITIALLY DEFERREDを付けた外部キーは検査をトランザクションの終わりまで遅らせられ、NO ACTIONならその間に子行を付け替えれば削除が通ります。RESTRICTは遅延を許さず、その場で拒否します。
遅延検査が役立つのは、親子を入れ替えるような一括更新や、相互参照する2表へ同時に投入する処理です。ただし遅れるのは検査だけで、同じ説明のとおりCASCADEなどの参照動作は遅延中でも即時に実行されます。トランザクションの範囲と検査時点の関係は、トランザクションの境界と分離レベルを解説した記事と合わせて設計してください。MySQLのInnoDBではNO ACTIONはRESTRICTと同じ扱いで、即座に拒否されます。
CASCADEを許してよい明細型データと許してはいけないマスタ参照
CASCADEを付けてよいのは、親が消えたら子だけ残っても意味を持たないデータです。注文に対する明細、記事に対する本文の版履歴、ユーザーに対するセッション行がこれに当たります。
付けてはいけないのは、マスタを参照する取引データです。顧客にON DELETE CASCADEで注文をぶら下げると、顧客1件の誤削除で注文と明細と請求までが連鎖して消えます。会計や監査の対象になる行は、顧客側を論理削除フラグで無効化する設計に寄せてください。
MySQL 8.4系のマニュアルには、カスケードで削除・更新された行ではトリガーが起動しないと明記されています。削除ログをトリガーで取っている設計にCASCADEを足すと、連鎖で消えた明細だけ記録が抜けます。
SET NULLの列指定とMySQLのInnoDBでは使えないSET DEFAULT
SET NULLは「作成者が退職して社員マスタから消えても、文書は残す」といった任意参照に向きます。PostgreSQLでは15のリリースノート(2022年10月13日)で、ON DELETE SET NULL (author_id)のように対象列を限定できるようになりました。(tenant_id, author_id)の複合外部キーで、テナントIDは残して担当者IDだけを消す、という書き方ができます。
SET DEFAULTは、MySQLのパーサーは構文として受け付けるものの、InnoDBとNDBはこの指定を含むテーブル定義を拒否します。PostgreSQLでは使えますが、既定値が親に無ければ削除自体が失敗します。DBを移す可能性があるなら避けてください。
稼働中のテーブルへ外部キーを後から追加するマイグレーション手順
外部キー無しで運用してきた本番テーブルへの後付けは、ロックと既存データの不整合の2つで止まります。
ADD FOREIGN KEYが親子の両表に取るロックと書き込み停止の範囲
PostgreSQLのALTER TABLEの説明によると、多くの制約追加はACCESS EXCLUSIVEロックを取りますが、ADD FOREIGN KEYはSHARE ROW EXCLUSIVEで済みます。ただし、このロックは制約を宣言する子テーブルだけでなく、参照先の親テーブルにも掛かります。
SHARE ROW EXCLUSIVEは読み取りを止めませんが、INSERT・UPDATE・DELETEを止めます。しかも通常の追加では、既存の全行が制約を満たすかを走査し終えるまでがロックの保持期間です。数千万行の注文テーブルなら、その間は注文の登録も顧客の更新も待たされます。
NOT VALIDで追加しVALIDATE CONSTRAINTで既存行を後から検証する
回避策は、制約の追加と既存行の検証を分けることです。NOT VALID付きで追加すると全件走査が省かれ、コマンドはすぐに終わります。新しく書き込まれる行はこの時点から検査されます。既存行の検証はVALIDATE CONSTRAINTで後から行い、こちらはSHARE UPDATE EXCLUSIVEしか取らないため、通常の読み書きと並行して走ります(親テーブルにはROW SHAREを取ります)。
-- 1) 子の外部キー列の索引を、書き込みを止めずに先に作る
CREATE INDEX CONCURRENTLY orders_customer_id_idx ON orders (customer_id);
-- 2) ロック待ちで後続の処理を詰まらせないよう上限を置き、検証なしで追加
SET lock_timeout = '3s';
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers (id)
NOT VALID;
-- 3) 既存行の検証は別トランザクションで実行する
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;
lock_timeoutを置くのは、長時間のクエリが親か子を掴んでいるとALTER TABLEがロック待ちになり、その後ろに通常の書き込みが行列を作るためです。失敗したら時間を置いて再実行すれば済みます。PostgreSQL 18のリリースノート(2025年9月25日)では、パーティションテーブルにもNOT VALIDの外部キーを付けられるようになりました。17系以前でパーティションテーブルに後付けする場合は、この2段階が使えない前提で計画を立ててください。
孤児行を先に洗い出すLEFT JOINの検査SQLと修正方針の決め方
VALIDATE CONSTRAINTは、親の無い子行(孤児行)が1件でもあれば失敗します。検証の前に必要なのは、次のSQLによる件数と中身の確認です。PostgreSQL・MySQL・SQLiteのいずれでも同じ形で動きます。
SELECT o.id, o.customer_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.customer_id IS NOT NULL
AND c.id IS NULL;
見つかった孤児行の扱いは、業務側の判断が要ります。削除してよいのはテストデータや途中で失敗した登録の残骸と確認できたものだけです。請求済みの注文が孤児になっているなら、消すのではなく「退会済み顧客」のような代替の親行を作って付け替えます。件数が多い場合は、修正を終えるまでNOT VALIDのまま運用し、新規の汚れだけ先に止めておく進め方も取れます。
foreign_key_checks無効化中に入った行は再有効化後も未検査
MySQLでダンプの復元や一括投入を速くするためにSET foreign_key_checks = 0を使う手順はよく見かけます。MySQL 8.4系のマニュアルは、この変数を再び有効にしてもテーブルの走査は行われず、無効の間に入った行は整合性を検査されないままになると説明しています。
つまり、投入データに孤児行が混ざっていても、エラーは一度も出ません。無効化して投入した後は、前のh3の孤児行検出SQLを全ての外部キーについて流し、0件を確認してから運用に戻してください。既存DBへの外部キーの後付けや、DB製品の移行と合わせてスキーマを整理したい場合は、データベース設計・移行支援の相談窓口で、孤児行の棚卸しから切替手順までをまとめて相談できます。
外部キーをDBで張るかアプリで持つかの判断基準と見送ってよい場面
複数のアプリやバッチが同じテーブルに書き込むならDB側で張る
アプリ側で参照整合性を担保する方式は、書き込み経路が1本のときにだけ成立します。業務システムでは画面のアプリに加えて夜間バッチや保守の手作業SQLも書き込み、1本がチェックを省けば孤児行が入ります。
張るコストは、子の書き込みごとに親の主キー索引を1回引くことと、親の削除時に子の索引を引くことで、索引があれば行単位の検索で済みます。書き込み経路が2本以上ある、または増える見込みがあるなら、DB側で張ってください。
Spannerの情報用外部キーとPostgreSQL 18のNOT ENFORCED
分散DBでは、外部キーの扱いに違いがある点に注意が必要です。Google CloudのSpannerの外部キー概要は、書き込み時に参照整合性を検証する「適用された外部キー」と、検証しない「情報用の外部キー」を区別した説明です。情報用は書き込みの負荷を増やさない代わりに、整合性の担保をアプリへ戻します。参照動作として指定できるのはON DELETE CASCADEとON DELETE NO ACTIONで、適用された外部キーに限られます。
PostgreSQLも18で外部キーにNOT ENFORCEDを指定できるようになりました。CREATE TABLEの説明では、DBは制約を検査せず、アプリのコードが満たす責任を負うとされ、実行時の検査が高すぎる場合の文書としての用途が挙げられています。関係をスキーマに残してER図生成ツールに読ませたいが検査はしない、という場面向けです。単一ノードのPostgreSQLで性能を理由に使うのは、索引を張って計測してからにしてください。
外部キーを張らずにアプリで担保してよい条件と典型的な失敗パターン
外部キーを見送ってよいのは、次の条件のどちらかに当てはまるときです。
- 参照先が別のデータベースや別サービスにあり、制約で表現できない(マイクロサービス間の顧客IDなど)
- 分散DBで、書き込み時の検証が性能要件を満たせないと計測で確認できた
この2つ以外の理由、たとえば「マイグレーションの順番が面倒」「テストデータを入れにくい」は見送る理由になりません。どちらも親から先に作る順序を決めれば解消します。見送った場合の典型的な失敗は、代替の検査バッチが後回しになり、数年後の移行時に数万件の孤児行が見つかる形です。見送るなら、孤児行検出SQLを日次で流す監視までを同じリリースに含めてください。
ORMのカスケード削除とDBのON DELETEを二重に設定する落とし穴
RailsやDjangoのORMのカスケード削除は、アプリが子を読み出して削除し、その過程でフックやシグナルを発火させます。DB側のON DELETE CASCADEはDBの中で完結し、アプリは子が消えたことを知りません。
両方を設定したまま、削除フックで監査ログを書いていると、どちらが先に子を消したかでログの有無が変わります。どちらで消すかを表ごとに1つに決め、ORMの設定とDBの制約定義を揃えてください。Djangoの場合の具体的な対応関係は、Djangoのon_deleteの7種の挙動と削除設計を扱った記事で、DB側の制約との対照表とともに確認できます。
外部キーの索引・NULL・一時無効化について実装者からよくある質問
設計と運用でよく出る質問に、判断に使える形で答えます。
外部キーとは簡単に言うと何ですか?
「この列の値は、あの表のこの列に存在する値でなければならない」というルールをデータベースに登録する仕組みです。注文表の顧客IDが顧客表の主キーを指すように宣言すると、存在しない顧客IDを持つ注文は保存できなくなり、注文が残っている顧客の削除も止められます。表同士の関係がスキーマに残るため、設計書の代わりにもなります。
外部キーの列にNULLを入れてもよいですか?
入れられます。外部キー列がNULLの行は既定では検査を受けないため、担当者が未定の案件のように「参照先が未確定」の状態を表せます。参照を必須にしたい場合は、外部キーに加えてNOT NULL制約を付けてください。複数列の外部キーでは片方だけNULLの行も素通りする点に注意が要ります。
外部キーにインデックスは必要ですか?
子テーブル側の外部キー列には、ほぼ必ず張るべきです。索引が無いと、親を削除・更新するたびに子テーブルを全件走査します。MySQLのInnoDBは自動で作りますが、PostgreSQLとSQLiteは作りません。親側の参照先列は主キーか一意制約なので、既に索引があります。
外部キーを張ると書き込み性能は落ちますか?
落ちますが、索引が揃っていれば子のINSERTごとに親の主キーを1回引く程度の上乗せです。問題になるのは、子の外部キー列に索引が無い状態で親を削除する場合と、CASCADEで大量の子行を一度に消す場合です。外す前に、索引の有無と削除の単位を見直してください。
外部キーの制約を一時的に無効にするにはどうしますか?
DBごとに方法が違います。MySQLはSET foreign_key_checks = 0、SQLiteはPRAGMA foreign_keys = OFFでセッション単位に無効化できます。PostgreSQLでは、制約をDEFERRABLEにしてトランザクション末尾で検査するか、いったん削除してNOT VALIDで付け直す方法が一般的です。いずれの方法でも無効の間に入った行は検査されないため、戻した後に孤児行検出SQLで0件を確認してください。
関連記事
- データベースとは?種類・DBMS・RDBとNoSQLの選び方を実装目線で解説:外部キーを含むリレーショナルモデル全体の位置づけと、RDBとNoSQLの選び方を整理しています。
- データベース正規化とは?第1〜第3正規形の手順と非正規化の判断を解説:表を分割した後に外部キーで結び直す手順へ進めます。
- ER図とは?書き方・エンティティやリレーション・カーディナリティを実装目線で解説:外部キーの宣言を図の上でどう表すかを確認できます。
- トランザクションとは?分離レベルの違い・ACID・commit/rollbackを実装目線で解説【2026年版】:遅延検査とトランザクション境界の関係を設計する際の土台になります。
- Django on_deleteの選び方:7種の挙動と6.1のDB_*系・削除設計:ORM側のカスケードとDB側の参照動作を揃える具体例を扱っています。