公開日 2026-05-06

PostgreSQLの外部キーと削除ルールを整理する(CASCADE / RESTRICT / SET NULL)

PostgreSQL の外部キーで `CASCADE` / `RESTRICT` / `SET NULL` をどう使い分けるかを、3 つの削除例と Mermaid 図で整理する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 最小デモ環境を作る
  4. 3. 削除ルールを Mermaid で先に俯瞰する
  5. 4. スキーマと seed を作る
  6. コードのポイント
  7. 5. CASCADE を確認する
  8. 6. RESTRICT を確認する
  9. 7. SET NULL を確認する
  10. 8. 使い分けを整理する
  11. 9. まとめ

親テーブルを削除したときに子テーブルがどう動くかは、外部キーの ON DELETE で決まります。この記事では CASCADE / RESTRICT / SET NULL の 3 つを、独立した小さなテーブル例で並べ、psql から削除前後の結果を確認します。

先に整理すると: CASCADE は子行も一緒に消す。RESTRICT は子行が残る間、親削除を止める。SET NULL は子行を残して外部キー列だけ NULL にする。3 つの違いを Docker + psql の実行結果で手を動かして確認します。

特定のプログラミング言語や ORM は対象外です。再現範囲は Docker + PostgreSQL + psql のみ。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop
  • PostgreSQL 17
  • psql

以降のコマンド実行場所は、特記がない限り WSL 側ターミナルです。

1. ゴールと非対象

到達する状態:

  • CASCADE / RESTRICT / SET NULL の削除結果を、自分の手で再現できる
  • どのルールが「子も消す」「削除を止める」「関係だけ外して子を残す」なのか整理できる
  • 業務テーブルでどれを選ぶべきかを、残したいデータから逆算できる

扱わない内容:

  • ON UPDATE
  • 論理削除の実装
  • ORM やマイグレーションツールの書き方
  • DEFERRABLE や制約タイミングの深掘り
  • 本番運用の監査ログや権限設計

今回は「親を削除したときに子がどうなるか」だけに集中します。
NO ACTION は既定値として 8 章で短く補足しますが、比較の主線は CASCADE / RESTRICT / SET NULL の 3 つです。

2. 最小デモ環境を作る

記事用の空ディレクトリを作ります。

mkdir -p ~/projects/postgresql-foreign-key-delete-rules-demo
cd ~/projects/postgresql-foreign-key-delete-rules-demo
mkdir -p sql
code .

最終的な構成は次の 4 ファイルです。

postgresql-foreign-key-delete-rules-demo/
├─ compose.yml
├─ .env.example
└─ sql/
   ├─ 01-schema.sql
   └─ 02-seed.sql

compose.yml の内容は次のとおりです。

services:
  db:
    image: postgres:17
    working_dir: /workspace
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./:/workspace
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  db-data:

今回はアプリケーションコンテナを置きません。外部キーの削除挙動だけを SQL で追いたいためです。

.env.example は次の内容で作成します。

POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app

起動は次のコマンドです。

cp .env.example .env
docker compose up -d
docker compose exec db pg_isready -U app -d app
docker compose exec db psql -U app -d app -c "SELECT version();"
pg_isready と SELECT version() の出力確認

確認ポイント:

  • pg_isreadyaccepting connections を返す
  • SELECT version(); が 1 行返る

詰まったとき:

docker compose logs db
docker compose down -v

3. 削除ルールを Mermaid で先に俯瞰する

先に全体像を図で置くと、このあと出てくる削除結果を追いやすくなります。

flowchart LR
    A["親テーブルの行を削除する"] --> B{"ON DELETE は何か"}
    B -->|CASCADE| C["関連する子行も削除される"]
    B -->|RESTRICT| D["削除を拒否してエラーにする"]
    B -->|SET NULL| E["子行は残し、外部キー列を NULL にする"]

今回使う 3 ケースの関係は次のとおりです。

erDiagram
    BLOG_POSTS ||--o{ POST_COMMENTS : "ON DELETE CASCADE"
    CUSTOMERS ||--o{ ORDERS : "ON DELETE RESTRICT"
    CATEGORIES |o--o{ PRODUCTS : "ON DELETE SET NULL"

    BLOG_POSTS {
        bigint id PK
        text title
    }
    POST_COMMENTS {
        bigint id PK
        bigint post_id FK
        text body
    }
    CUSTOMERS {
        bigint id PK
        text customer_name
    }
    ORDERS {
        bigint id PK
        bigint customer_id FK
        text order_number
    }
    CATEGORIES {
        bigint id PK
        text category_name
    }
    PRODUCTS {
        bigint id PK
        bigint category_id FK "nullable"
        text product_name
    }

見分け方は次の 3 行です。

  • CASCADE: 親にぶら下がる子行も一緒に消す
  • RESTRICT: 子行が残っている間は親を消させない
  • SET NULL: 子行は残し、親との参照だけ外す

SET NULL だけは、子側の外部キー列が NULL を受け入れられる必要があります。NOT NULL のままだと、このルールは成立しません。 そのため categoriesproducts の関係は、「商品はカテゴリなしでも残れる」形です。

4. スキーマと seed を作る

sql/01-schema.sql は次の内容で作成します。

CREATE TABLE blog_posts (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title TEXT NOT NULL
);

CREATE TABLE post_comments (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  post_id BIGINT NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
  body TEXT NOT NULL
);

CREATE TABLE customers (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_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,
  order_number TEXT NOT NULL UNIQUE
);

CREATE TABLE categories (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  category_name TEXT NOT NULL
);

CREATE TABLE products (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  category_id BIGINT REFERENCES categories(id) ON DELETE SET NULL,
  product_name TEXT NOT NULL
);

外部キーの着眼点は次の 3 点。

  • post_comments.post_idON DELETE CASCADE にして、投稿を消したらその投稿のコメントも一緒に消す
  • orders.customer_idON DELETE RESTRICT にして、受注が残る顧客は削除させない
  • products.category_idON DELETE SET NULL にして、カテゴリ削除後も商品行だけは残す

SET NULL を使う products.category_id だけは NOT NULL を付けていません。
ここが NOT NULL だと、親削除時に NULL へ更新できず失敗します。

sql/02-seed.sql は次の内容で作成します。

TRUNCATE TABLE
  post_comments,
  blog_posts,
  orders,
  customers,
  products,
  categories
RESTART IDENTITY CASCADE;

INSERT INTO blog_posts (title)
VALUES
  ('外部キーの基本を整理する'),
  ('psql チートシート');

INSERT INTO post_comments (post_id, body)
VALUES
  (1, '親子関係を図で見ると分かりやすいです。'),
  (1, '削除ルールも続編で読みたいです。'),
  (2, '\d をよく使います。');

INSERT INTO customers (customer_name)
VALUES
  ('佐藤商店'),
  ('青山カフェ');

INSERT INTO orders (customer_id, order_number)
VALUES
  (1, 'ORD-1001');

INSERT INTO categories (category_name)
VALUES
  ('PC周辺機器'),
  ('書籍');

INSERT INTO products (category_id, product_name)
VALUES
  (1, 'USB-Cドック'),
  (1, '27インチモニター'),
  (2, 'PostgreSQL実践ガイド');

この seed には TRUNCATE ... RESTART IDENTITY CASCADE を入れています。各ケースの前に同じ初期状態へ戻したいからです。

スキーマと seed を適用します。

docker compose exec db psql -U app -d app -f sql/01-schema.sql
docker compose exec db psql -U app -d app -f sql/02-seed.sql

続いて \d で、各テーブルの外部キーを確認します。

docker compose exec db psql -U app -d app -c "\\d post_comments"
docker compose exec db psql -U app -d app -c "\\d orders"
docker compose exec db psql -U app -d app -c "\\d products"

ここで見る点は次の 3 つです。

  • post_commentsON DELETE CASCADE
  • ordersON DELETE RESTRICT
  • productsON DELETE SET NULL
\\d で ON DELETE ルールを確認した出力

コードのポイント

CREATE TABLE blog_posts (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title TEXT NOT NULL
);

CREATE TABLE post_comments (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  post_id BIGINT NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
  body TEXT NOT NULL
);

CREATE TABLE customers (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_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,
  order_number TEXT NOT NULL UNIQUE
);

CREATE TABLE categories (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  category_name TEXT NOT NULL
);

CREATE TABLE products (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  category_id BIGINT REFERENCES categories(id) ON DELETE SET NULL,
  product_name TEXT NOT NULL
);
  • 8行目 ON DELETE CASCADE — 親 blog_posts を削除すると、関連する post_comments 行も連動して削除される。子が NOT NULL のままでも使えるのは、参照先を失った子行をそのまま消すルールだから。
  • 19行目 ON DELETE RESTRICTorders が残っている間は、参照先の customers 行の削除をエラーで止める。注文履歴を誤って消さないための守り。
  • 30行目 ON DELETE SET NULLcategories 削除時に products.category_idNULL で上書きする。8・19行目と違って NOT NULL を付けていないのはこのため。NOT NULL のままだと親削除時の NULL 書き込みが失敗し、SET NULL ルール自体を設定できない。

5. CASCADE を確認する

初期状態へ戻します。

docker compose exec db psql -U app -d app -f sql/02-seed.sql

CASCADE の流れは次の図です。

sequenceDiagram
    participant App
    participant DB

    App->>DB: DELETE FROM blog_posts WHERE id = 1;
    DB->>DB: post_comments の関連行を削除
    DB->>DB: blog_posts の対象行を削除
    DB-->>App: DELETE 1

次が削除前の確認 SQL です。

SELECT
  p.id AS post_id,
  p.title,
  c.id AS comment_id,
  c.body
FROM blog_posts AS p
LEFT JOIN post_comments AS c
  ON c.post_id = p.id
ORDER BY p.id, c.id;
docker compose exec db psql -U app -d app -c "
SELECT
  p.id AS post_id,
  p.title,
  c.id AS comment_id,
  c.body
FROM blog_posts AS p
LEFT JOIN post_comments AS c
  ON c.post_id = p.id
ORDER BY p.id, c.id;
"

期待する結果:

post_idtitlecomment_idbody
1外部キーの基本を整理する1親子関係を図で見ると分かりやすいです。
1外部キーの基本を整理する2削除ルールも続編で読みたいです。
2psql チートシート3\d をよく使います。
CASCADE 削除前:blog_posts と post_comments の結合結果

親行の削除は次の 1 文。

DELETE FROM blog_posts
WHERE id = 1;
docker compose exec db psql -U app -d app -c "DELETE FROM blog_posts WHERE id = 1;"

そのあと同じ SELECT をもう一度流します。

docker compose exec db psql -U app -d app -c "
SELECT
  p.id AS post_id,
  p.title,
  c.id AS comment_id,
  c.body
FROM blog_posts AS p
LEFT JOIN post_comments AS c
  ON c.post_id = p.id
ORDER BY p.id, c.id;
"

期待する結果:

post_idtitlecomment_idbody
2psql チートシート3\d をよく使います。
CASCADE 削除後:id=1 の投稿とコメントが消えた結果

id = 1 の投稿だけでなく、その投稿にぶら下がっていたコメント 2 行も消えます。
このルールが向くのは、「親が消えたら子だけ残っていても意味を持ちにくい」ケースです。コメントだけ残しても、どの記事のコメントか分からなくなるからです。

6. RESTRICT を確認する

もう一度 seed を流し直します。

docker compose exec db psql -U app -d app -f sql/02-seed.sql

RESTRICT の流れは次の図です。

sequenceDiagram
    participant App
    participant DB

    App->>DB: DELETE FROM customers WHERE id = 1;
    DB-->>App: ERROR (foreign key violation)

削除前の確認 SQL は次のとおりです。

SELECT
  c.id AS customer_id,
  c.customer_name,
  o.order_number
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id
ORDER BY c.id, o.id;
docker compose exec db psql -U app -d app -c "
SELECT
  c.id AS customer_id,
  c.customer_name,
  o.order_number
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id
ORDER BY c.id, o.id;
"

期待する結果:

customer_idcustomer_nameorder_number
1佐藤商店ORD-1001
2青山カフェNULL
RESTRICT 削除前:customers と orders の結合結果

削除する SQL は次の 1 文です。

DELETE FROM customers
WHERE id = 1;
docker compose exec db psql -U app -d app -c "DELETE FROM customers WHERE id = 1;"

結果は次のエラーです。

ERROR:  update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders"
DETAIL:  Key (id)=(1) is still referenced from table "orders".
RESTRICT による外部キー違反エラーの出力

もう一度同じ SELECT を流すと、結果は変わっていません。

docker compose exec db psql -U app -d app -c "
SELECT
  c.id AS customer_id,
  c.customer_name,
  o.order_number
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id
ORDER BY c.id, o.id;
"
customer_idcustomer_nameorder_number
1佐藤商店ORD-1001
2青山カフェNULL
RESTRICT 後:テーブルが変化していないことを確認した出力

RESTRICT は「親を消す前に、先に子をどう扱うか決めてください」というルールです。
受注履歴を残したいのに、顧客削除に引っ張られて注文まで消えると困る場面では、この挙動のほうが安全です。

customer_id = 2 のように注文がひも付いていない顧客なら、同じ DELETE が成功します。止まるのは「参照されている親行」だけです。

7. SET NULL を確認する

ここでも seed を流し直します。

docker compose exec db psql -U app -d app -f sql/02-seed.sql

SET NULL の流れは次の図です。

sequenceDiagram
    participant App
    participant DB

    App->>DB: DELETE FROM categories WHERE id = 1;
    DB->>DB: products.category_id を NULL に更新
    DB->>DB: categories の対象行を削除
    DB-->>App: DELETE 1

削除前の確認 SQL は次のとおりです。

SELECT
  p.id AS product_id,
  p.product_name,
  p.category_id,
  c.category_name
FROM products AS p
LEFT JOIN categories AS c
  ON c.id = p.category_id
ORDER BY p.id;
docker compose exec db psql -U app -d app -c "
SELECT
  p.id AS product_id,
  p.product_name,
  p.category_id,
  c.category_name
FROM products AS p
LEFT JOIN categories AS c
  ON c.id = p.category_id
ORDER BY p.id;
"

期待する結果:

product_idproduct_namecategory_idcategory_name
1USB-Cドック1PC周辺機器
227インチモニター1PC周辺機器
3PostgreSQL実践ガイド2書籍
SET NULL 削除前:products と categories の結合結果

削除する SQL は次の 1 文。

DELETE FROM categories
WHERE id = 1;
docker compose exec db psql -U app -d app -c "DELETE FROM categories WHERE id = 1;"

そのあと同じ SELECT をもう一度流します。

docker compose exec db psql -U app -d app -c "
SELECT
  p.id AS product_id,
  p.product_name,
  p.category_id,
  c.category_name
FROM products AS p
LEFT JOIN categories AS c
  ON c.id = p.category_id
ORDER BY p.id;
"

期待する結果:

product_idproduct_namecategory_idcategory_name
1USB-CドックNULLNULL
227インチモニターNULLNULL
3PostgreSQL実践ガイド2書籍
SET NULL 削除後:category_id が NULL になり商品行は残った結果

カテゴリ行は消えていますが、products の 1 行目と 2 行目は残っています。変わったのは category_id だけです。
このルールが向くのは、「親との関係は外してよいが、子そのものは残したい」ケースです。商品自体は残し、あとで別カテゴリへ付け替える余地を残せます。

products.category_idNOT NULL を付けていないのは、SET NULL が親削除時に子列を NULL で更新するためです。子列が NOT NULL だとその更新が失敗し、SET NULL 自体を使えません。

8. 使い分けを整理する

3 つの違いを表に戻すと、判断しやすくなります。

ルール親削除時の子の挙動向く場面今回の例
CASCADE関連する子行も一緒に削除する子だけ残っても意味を持ちにくいblog_posts -> post_comments
RESTRICT親削除を拒否してエラーにする履歴や受注を消したくないcustomers -> orders
SET NULL子行は残し、外部キー列だけ NULL にする関係だけ外して子を残したいcategories -> products

選び方の目線は 3 つです。

  • 子行を親なしで残してよいか
  • 親削除の前に、人が確認する手順を入れたいか
  • 子列を nullable にしても業務上破綻しないか

CASCADE は便利ですが、「関連もまとめて片付く」以上の意味があります。意図しない削除まで連鎖するなら、まず止める側の RESTRICT を検討したほうが安全です。

何も書かなかった場合、PostgreSQL の既定は NO ACTION です。
即時チェックの場面では RESTRICT と似て見えることが多いものの、厳密には同じではありません。この記事では「親削除をその場で止めたい」意図を明示するために RESTRICT を使いました。

9. まとめ

今回確認したことは次の 3 つです。

  • CASCADE は親削除に連動して子行も消える
  • RESTRICT は子行が残る間、親削除を止める
  • SET NULL は子行を残しつつ、参照だけ外す

Mermaid を先に置く利点は、「どの行が消えるのか」「どの列が NULL になるのか」を SQL の前からイメージしやすいことです。

制約の種類と役割を整理したいなら PostgreSQLの制約入門(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY)、外部キーの向きそのものをまだ迷うなら PostgreSQLでER図からテーブル設計する(Mermaid + CREATE TABLE最小構成) がつながります。アプリから削除処理まで進めたいなら、WSL2 + Docker + PHP + PostgreSQLで最小CRUDを作る に進むと流れがつかみやすくなります。

シリーズ 3/3

このシリーズ

PostgreSQL基礎

  1. 1. PostgreSQLでER図からテーブル設計する(Mermaid + CREATE TABLE最小構成)
  2. 2. PostgreSQLの制約入門(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY)
  3. 3. PostgreSQLの外部キーと削除ルールを整理する(CASCADE / RESTRICT / SET NULL) 現在の記事