親テーブルを削除したときに子テーブルがどう動くかは、外部キーの 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がaccepting 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 のままだと、このルールは成立しません。
そのため categories と products の関係は、「商品はカテゴリなしでも残れる」形です。
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_idはON DELETE CASCADEにして、投稿を消したらその投稿のコメントも一緒に消すorders.customer_idはON DELETE RESTRICTにして、受注が残る顧客は削除させないproducts.category_idはON 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_commentsにON DELETE CASCADEordersにON DELETE RESTRICTproductsにON DELETE SET NULL
コードのポイント
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 RESTRICT—ordersが残っている間は、参照先のcustomers行の削除をエラーで止める。注文履歴を誤って消さないための守り。 - 30行目
ON DELETE SET NULL—categories削除時にproducts.category_idをNULLで上書きする。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_id | title | comment_id | body |
|---|---|---|---|
| 1 | 外部キーの基本を整理する | 1 | 親子関係を図で見ると分かりやすいです。 |
| 1 | 外部キーの基本を整理する | 2 | 削除ルールも続編で読みたいです。 |
| 2 | psql チートシート | 3 | \d をよく使います。 |
親行の削除は次の 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_id | title | comment_id | body |
|---|---|---|---|
| 2 | psql チートシート | 3 | \d をよく使います。 |
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_id | customer_name | order_number |
|---|---|---|
| 1 | 佐藤商店 | ORD-1001 |
| 2 | 青山カフェ | NULL |
削除する 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".
もう一度同じ 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_id | customer_name | order_number |
|---|---|---|
| 1 | 佐藤商店 | ORD-1001 |
| 2 | 青山カフェ | NULL |
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_id | product_name | category_id | category_name |
|---|---|---|---|
| 1 | USB-Cドック | 1 | PC周辺機器 |
| 2 | 27インチモニター | 1 | PC周辺機器 |
| 3 | PostgreSQL実践ガイド | 2 | 書籍 |
削除する 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_id | product_name | category_id | category_name |
|---|---|---|---|
| 1 | USB-Cドック | NULL | NULL |
| 2 | 27インチモニター | NULL | NULL |
| 3 | PostgreSQL実践ガイド | 2 | 書籍 |
カテゴリ行は消えていますが、products の 1 行目と 2 行目は残っています。変わったのは category_id だけです。
このルールが向くのは、「親との関係は外してよいが、子そのものは残したい」ケースです。商品自体は残し、あとで別カテゴリへ付け替える余地を残せます。
products.category_id に NOT 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を作る に進むと流れがつかみやすくなります。