---
title: "Django on_deleteの選び方：7種の挙動と6.1のDB_*系・削除設計"
url: "https://www.issoh.co.jp/tech/details/17111/"
published: 2026-08-28
updated: 2026-08-28
categories: ["開発"]
publisher: "株式会社一創"
---

# Django on\_deleteの選び方：7種の挙動と6.1のDB\_\*系・削除設計

Djangoで`ForeignKey`を書くとき、型やフィールド名は迷わないのに`on_delete`だけ手が止まる、という経験は多いはずです。ここは「参照先が消えたら参照元をどうするか」という業務仕様をコードで宣言する場所で、[Djangoとは？特徴・メリットとMVTの仕組みを入門解説【最新版対応】](https://www.issoh.co.jp/tech/details/3275/)で扱ったORMの層に属します。選び方を誤ると、削除ボタンひとつで関連データが連鎖して消える、あるいは何年も削除できない行が残る、という形で後から表面化します。ここでは6.1系を前提に、7種の挙動と例外の型、6.1で追加されたデータベースレベルの選択肢、業務データでの線引き、後から変えたときの影響までを実装の順で整理しました。

## まとめ：on\_deleteは削除の可否と痕跡の残し方を決める

- Python側の値は7種です。消す（CASCADE）、止める（PROTECT・RESTRICT）、参照だけ書き換える（SET\_NULL・SET\_DEFAULT・SET()）、何もしない（DO\_NOTHING）の4系統に分かれます。
- PROTECTとRESTRICTの差は例外名だけではありません。同一操作内でCASCADE経由に消える場合、RESTRICTは通します。
- Django 6.1でDB\_CASCADE・DB\_SET\_NULL・DB\_SET\_DEFAULTが入りました。データベース側で完結する代わりに削除シグナルは飛びません。
- Python側の値はDjangoのエミュレーションなので、生成されるDDLにON DELETE句は出ません。制約は参照整合性のためだけに張られます。
- 業務データで消してはいけない行はPROTECTで止め、状態列で「消えたように見せる」方向へ寄せます。
- Python側どうしの変更はDDLを伴いませんが、DB\_\*系との出入りは制約の張り替えになります。ここだけ本番で段取りが必要です。

## on\_deleteが必須引数である理由とDjangoが削除を担う構造

公式ドキュメントは`on_delete`を、参照元をどう更新するか、そして**Djangoとデータベースのどちらが更新を行うか**を指定する引数だと説明しています。後半が効いてくるのは6.1以降で、それまでは実質「Djangoが行う」しか選べませんでした。

### 削除の起点はモデルではなくQuerySetとCollectorにある

`obj.delete()`や`queryset.delete()`を呼ぶと、Djangoは内部で`Collector`というオブジェクトを組み立てます。これが、消す対象から辿れる関連行を集め、`on_delete`ごとに登録されたハンドラを呼び、最後に削除と更新をまとめて発行する役目です。戻り値は削除総数とモデル別の件数を持つタプルで、公式の例では`(4, {'Song': 2, 'Album': 1, 'Artist': 1})`のような形になります。

この構造から分かるのは、`on_delete`が「データベースの設定」ではなく「Djangoのアプリケーション側のふるまい」だという点です。`psql`から直接`DELETE`を打てばPython側の`on_delete`は動かず、管理画面から消せば動きます。[Django管理画面の設計：admin標準の範囲とModelAdmin・権限の実装](https://www.issoh.co.jp/tech/details/17078/)で触れているとおり、adminの削除確認画面に連鎖対象が並ぶのは、この`Collector`が事前に集めた結果です。

### Model.deleteが呼ばれない範囲とシグナルが届く範囲の差

ここに1つ、実装で踏みやすい落とし穴があります。CASCADEで連鎖削除された関連モデルについて、公式は`Model.delete()`は呼ばれないと明記しています。つまり、`delete()`をオーバーライドしてファイル削除や外部APIの後処理を書いていても、連鎖で消えた側では走りません。

一方、`pre_delete`と`post_delete`のシグナルは削除された全オブジェクトに対して送られます。後処理を確実に走らせたいなら、`delete()`のオーバーライドではなくシグナル側へ置くのが正解です。この差を知らないまま`delete()`に副作用を書くと、単体の削除では動くのに親経由の削除では動かない、という再現しにくい挙動になります。

## Python側で動く7種の挙動と発生する例外の型を実装で見分ける

実務で使うのは3つか4つですが、残りを知らないと止めたい削除を止められません。

### CASCADEとPROTECTとRESTRICTの分岐条件の違い

CASCADEはSQLの`ON DELETE CASCADE`をDjangoがエミュレートし、`ForeignKey`を持つ側のオブジェクトも削除します。注文と注文明細のように、親が消えたら子に存在意義が残らない関係に限って使うのが安全です。

PROTECTは削除そのものを止め、`ProtectedError`を送出します。RESTRICTも止めますが例外は`RestrictedError`で、どちらも`django.db.IntegrityError`のサブクラスです。両者の違いは例外名ではなく分岐条件にあります。公式ドキュメントの説明では、RESTRICTはPROTECTと異なり、参照先が**同一操作内でCASCADE関係を通じて削除される別のオブジェクトも参照している場合**は削除を許可します。

```
class Artist(models.Model):
    name = models.CharField(max_length=10)


class Album(models.Model):
    artist = models.ForeignKey(Artist, on_delete=models.CASCADE)


class Song(models.Model):
    artist = models.ForeignKey(Artist, on_delete=models.CASCADE)
    album = models.ForeignKey(Album, on_delete=models.RESTRICT)
```

この定義では`album_one.delete()`は`RestrictedError`で止まりますが、`artist_one.delete()`は通ります。`Song`が`Artist`もCASCADEで参照しているため、`Album`を消す時点で`Song`も同じ操作で消えることが確定しているからです。「中間テーブルを介した親の削除は許すが、中間テーブル単独の削除は許さない」という仕様を表現したいときに、RESTRICTが効きます。

### SET\_NULLとSET\_DEFAULTとSET()が更新に落ちる場面

この3つは削除ではなく`UPDATE`を発行します。SET\_NULLは参照を`NULL`にしますが、公式が明記しているとおり`null=True`が付いていなければ使えません。SET\_DEFAULTは`ForeignKey`にデフォルト値の設定が必要です。

SET()は値または呼び出し可能オブジェクトを受け取ります。公式は、`models.py`のインポート時にクエリが走るのを避けるため、ほとんどの場合は呼び出し可能オブジェクトを渡す必要があると述べています。退会したユーザーの投稿を「削除済みユーザー」に付け替える定番の書き方がこれです。

```
def get_sentinel_user():
    return get_user_model().objects.get_or_create(username="deleted")[0]


class Comment(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET(get_sentinel_user),
    )
```

ソースを読むと細かい差もあります。`SET_NULL`と`SET(値)`には`lazy_sub_objs`という印が付き、更新対象の行をオブジェクトとして読み込まずに済みます。`SET(呼び出し可能オブジェクト)`には付きません。数万行を付け替える場面では、この差が実行時間に出ます。

### DO\_NOTHINGを選んだときにデータベース側で必要になる設定

DO\_NOTHINGはDjango側で何もしません。公式は、データベースバックエンドが参照整合性を強制する場合、フィールドにSQLの`ON DELETE`制約を手動で追加しない限り`IntegrityError`になると述べています。つまり「何もしない」は「制約違反で落ちる」と同義になりがちで、単体で選ぶ値ではありません。

使い道は2つです。データベース側でトリガーやパーティション運用を組んでいて削除の面倒をアプリに見せたくない場合と、後述の`db_constraint`を`False`にしてそもそも制約を張らない場合。どちらも「意図してDjangoの外に出した」という記録を残しておかないと、次の担当者が原因を追えません。

## Django 6.1で入ったDB\_\*系とfast-deleteが効く条件

2026年8月5日にリリースされたDjango 6.1（対応Pythonは3.12から3.14）は、`on_delete`にデータベースレベルの選択肢を追加しました。「Djangoがエミュレートする」しかなかった部分が、初めて選べるようになった変更です。

### DB\_CASCADEでシグナルが飛ばなくなる代償と得られる速度

追加されたのは`DB_CASCADE`・`DB_SET_NULL`・`DB_SET_DEFAULT`の3つです。リリースノートは、これらがSQLの`ON DELETE`句を使って削除ロジックをデータベース内で完結させ、Djangoが削除前にオブジェクトを読み込む必要がないためPython側の選択肢より効率が良い、と説明しています。そして帰結として、`DB_CASCADE`は`pre_delete`と`post_delete`を発火させません。

判断はここで決まります。連鎖削除に紐づく後処理（監査ログの記録、ストレージ上のファイル削除、検索インデックスの更新）をシグナルで組んでいるなら、`DB_CASCADE`へ移した瞬間にそれらが止まります。後処理が無く行数の大きいテーブルなら、読み込みが消える効果は素直に出るはずです。`DB_SET_DEFAULT`は`Field.db_default`の値を使う点と、MySQLおよびMariaDBで非対応な点も併せて確認してください。

### fast-deleteが選ばれる条件とCASCADEで外れる理由

削除が遅い、メモリを食う、という相談の多くはここに原因があります。Djangoは条件がそろうと、対象を読み込まず`DELETE`文1本で済ませる経路（fast-delete）を選びます。削除処理の実装にある判定メソッドを読むと、条件は次のとおりです。

| 条件         | 外れる原因                |
| ---------- | -------------------- |
| シグナル購読が無い  | post\_deleteを1本繋いだ   |
| 向きFKが集約対象外 | CASCADEやSET\_NULLが有る |
| 多重継承の親が無い  | 抽象でない基底モデル           |

2行目が本題です。ソースでは、当該モデルへ向かう全ての`ForeignKey`の`on_delete`が集約を省ける集合に含まれることを条件にしています。この集合の中身は`DO_NOTHING`と`DB_CASCADE`と`DB_SET_DEFAULT`と`DB_SET_NULL`の4つで、Python側のCASCADEやSET\_NULLは入っていません。子テーブルへCASCADEを1本張るだけで、親の削除は「子を全件読み込んでから消す」経路に落ちます。クエリ本数が対象行数に比例して膨らむ構図そのものは[N+1問題とは？仕組みと検出方法・ORM別の対策【実測クエリ数つき】](https://www.issoh.co.jp/tech/details/4262/)と同じで、削除でも同型の問題が起きると考えてください。

### Python系とDB\_\*系を同じモデルの中で混在できない制約

移行時に外せない制約があります。公式は、データベース側の値は`DO_NOTHING`以外のPython側の値と、**同一モデル内および相互に関連するモデル内で混在させられない**、というのが公式の記述です。「重いテーブルの1本だけ`DB_CASCADE`にする」という部分適用は成立せず、関連するモデル群をまとめて切り替える単位で考えることになります。

結果として、既存プロジェクトでの採用は「関連の島」ごとの判断になります。ログや計測値のように後処理を持たない一群は移しやすく、ユーザーや受注のようにシグナルと業務ロジックが集まっている一群はPython側に残すほうが安全です。

## 業務データで論理削除を選ぶ場面と物理削除のままにしておく線引き

実務でより多いのは「そもそも消してよいのか」の判断で、`on_delete`はその結論を書き写す場所にすぎません。

### 監査要件が絡む行はPROTECTで止めて状態列へ落とす設計にする

受注、請求、入出庫、契約のように、後から「その時点で何があったか」を証明する必要があるデータは、行を消さない前提で設計します。参照される側（顧客、商品、部門）の`on_delete`はPROTECTにして、参照が1本でも残っていれば削除を止める形です。画面から消えてほしいという要求には、状態列で応えます。

```
class Customer(models.Model):
    name = models.CharField(max_length=100)
    is_archived = models.BooleanField(default=False)


class Order(models.Model):
    customer = models.ForeignKey(
        Customer,
        on_delete=models.PROTECT,
    )
```

この形にすると、削除は例外で止まり、運用は`is_archived`を立てる操作に置き換わります。既定の一覧から外す処理はマネージャ側へ寄せ、集計や履歴照会は全件を見るマネージャを使い分ける形です。クエリ側の書き分けは[Django ORMのクエリ設計｜遅延評価と集計の順序・生SQLへ落とす判断](https://www.issoh.co.jp/tech/details/17082/)の考え方をそのまま使えます。

逆に、セッションや一時ファイルのメタ情報、外部APIのキャッシュなど、消えても再現できるデータは物理削除で構いません。ここに論理削除を持ち込むと、行が増え続けるだけで得るものがありません。判断の分かれ目は「後から証明を求められるか」の一点です。

### ユーザーを削除しても投稿を残すときのSET()の書き方と注意

会員制のサービスで頻出するのが、退会したユーザーの投稿やコメントをどうするかという要求です。全部消すならCASCADE、投稿だけ残すならSET\_NULLかSET()になります。SET\_NULLは実装が軽い代わりに、表示側で毎回`None`を考慮しなければなりません。SET()でセンチネル行へ寄せると、表示側は通常のユーザーと同じ扱いで書けます。

注意点は2つです。1つは、センチネル行そのものが誤って消されないよう、参照する側をPROTECTで守るか削除操作を管理画面から外しておくこと。もう1つは個人情報の扱いで、投稿本文に氏名や連絡先が残っていれば、ユーザー行を消しただけでは削除要求に応えたことになりません。`on_delete`で解決できるのは参照の付け替えまで、と切り分けて考えてください。

## データベース側の外部キー制約とon\_deleteの実際の対応関係

「PROTECTにしたのに、DBから直接消したら消えた」という報告は、対応関係を知らないと不具合に見えますが、仕様どおりの挙動です。

### PostgreSQLの外部キー制約が遅延可能として張られる意味

Djangoの外部キー生成テンプレートは、参照先の指定に続けて`on_delete`由来のSQLと遅延指定を差し込む形になっています。ここで呼ばれる内部メソッドは、対応するSQLを持たない値に対して空文字を返します。Python側の7種はいずれもそのSQLを持たないため、**生成されるDDLに`ON DELETE`句は出ません**。

```
ALTER TABLE "shop_order"
    ADD CONSTRAINT "shop_order_customer_id_fk"
    FOREIGN KEY ("customer_id")
    REFERENCES "shop_customer" ("id")
    DEFERRABLE INITIALLY DEFERRED;
```

末尾の遅延指定はPostgreSQL固有です。PostgreSQL向けのバックエンドが`DEFERRABLE INITIALLY DEFERRED`を返すため、制約の検査はトランザクションのコミット時まで遅れます。親を消してから子を消す順序で書き込んでも途中で弾かれないのは、この指定のおかげです。接続側の設定は[DjangoとPostgreSQLの接続設定｜DATABASES定義と接続プール・固有フィールドの判断](https://www.issoh.co.jp/tech/details/17105/)にまとめています。

つまり、データベースに張られているのは参照整合性を守る制約だけで、削除時の動作はDjango側が持っています。ORMを経由しない削除で動作が抜け落ちるのはこのためです。6.1のDB\_\*系を選んだ場合だけ、この空文字の位置に`ON DELETE CASCADE`などが実際に入ります。

### db\_constraintをFalseにしてよい場面と失われる保証

制約そのものを張らない選択もあります。公式は`db_constraint`の既定は`True`で、これがほぼ確実に望ましい設定であり、`False`にするとデータ整合性にとって非常に悪くなり得ると書いています。想定場面として挙げているのは、正しくないレガシーデータがある場合と、データベースをシャーディングしている場合の2つだけです。

`False`にしたとき、存在しない関連オブジェクトへアクセスすると`DoesNotExist`が送出されます。つまり壊れた参照はコードの実行時まで発見されません。既存システムをDjangoへ載せ替える途中で一時的に外す判断はあり得ますが、その場合は「いつ戻すか」を課題として残しておく前提です。なお`ForeignKey`にはインデックスが自動作成されるため、結合のためではなく整合性のためだけに外部キーを置く場面では`db_index`を`False`にして重複を避けられます。

## on\_deleteを後から変えるときのマイグレーションの影響

設計を見直して`CASCADE`を`PROTECT`へ変える作業は珍しくありません。このときマイグレーションで何が起きるかは、Python側どうしか、DB\_\*系との出入りかで分かれます。

### Python系どうしの変更がDDLを伴わないと言える実装上の理由

根拠はフィールド定義側の実装にあります。`ForeignKey`は、`on_delete`がデータベースレベルの値でない場合に限り、`on_delete`を「データベースに関係しない属性」の一覧へ加えます。この一覧に入っている属性の変更は、スキーマ側の変更が不要と判断される仕組みです。

したがって、CASCADEからPROTECTへ変えると`makemigrations`は`AlterField`を含むファイルを作りますが、`sqlmigrate`で中身を見ると実質的なDDLは出ません。テーブルロックを心配せずに当てられる変更です。ファイル生成と適用の流れそのものは[Django migrateの実務｜makemigrationsからの流れと本番適用・巻き戻し](https://www.issoh.co.jp/tech/details/17080/)にまとめてあります。

```
python manage.py makemigrations shop
python manage.py sqlmigrate shop 0003
```

ただし、DDLが出ないことと「安全に切り替わる」ことは別です。CASCADEをPROTECTへ変えた瞬間から、これまで通っていた削除処理が`ProtectedError`で落ちます。切り替え前に「いま何件が削除不能になるか」を数えておく段取りが要ります。

### DB\_\*系へ移すときに制約を張り替える手順と本番での適用順序

DB\_\*系は事情が変わります。ソースのコメントが述べているとおり、データベースレベルの`on_delete`は列定義の一部として扱われる値です。関係しない属性の一覧から外れるため、変更すると外部キー制約の削除と再作成、つまり実際のDDLが発行されます。

本番での順序は4段に組みます。（1）対象の関連の島に属するモデルを洗い出し、混在の制約に引っかからないか確認する。（2）削除シグナルの購読を検索し、`DB_CASCADE`で失われる後処理をアプリ側の明示的な処理へ移す。（3）制約の張り替えを単独のマイグレーションに分け、行数の大きいテーブルでは適用時間を検証してから当てる。（4）切り替え後に削除の実測を取り、クエリ本数と所要時間が想定どおり下がったかを見る。

既存のデータベースをDjangoの管理下へ載せ替える場面では、この判断が制約の設計とまとめて必要になります。稼働中のシステムを止めずに構造を移す進め方の相談は[システムリプレイス・マイグレーション](https://www.issoh.co.jp/service/system/migration/)で承っています。

## よくある質問

### on\_deleteは何を指定すればよいですか？

迷ったらPROTECTから始めるのが安全です。CASCADEを既定にすると、想定外の連鎖削除がテストで見つからないまま本番へ出ます。PROTECTなら誤った削除は例外で止まるため、開発中に「ここは消せる関係か」を判断せざるを得ません。そのうえで、親が消えたら子に存在意義が残らない関係だけCASCADEへ変えていく順序をおすすめします。

### CASCADEとPROTECTのどちらを既定にすべきですか？

データの性質で分けてください。監査や証明が求められる業務データ（受注・請求・入出庫・契約）はPROTECT、親と一体の明細行や添付メタはCASCADE、消えても再現できるログや一時データはDB\_CASCADEという整理です。この3分類をコーディング規約に書いておくと判断がぶれません。

### RESTRICTとPROTECTはどう使い分けますか？

参照が2段以上に伸びている場合に差が出ます。親を消すときは孫も一緒に消えてよいが、孫だけを単独で消すのは許さない、という仕様ならRESTRICTです。公式のArtist・Album・Song例がそのケースにあたります。参照が1段しかない場合、両者の違いは例外の型だけです。

### DB\_CASCADEに切り替えると何が壊れますか？

削除シグナルに依存した後処理が止まります。公式が明記しているとおり、`DB_CASCADE`では`pre_delete`と`post_delete`が送られません。監査ログやファイル削除をシグナルで組んでいる場合、削除は速くなる一方で後処理が抜け落ちます。切り替え前に購読箇所を洗い出してください。

### on\_deleteを変更するとデータは消えますか？

変更そのものでデータが消えることはありません。`on_delete`は今後の削除操作のふるまいを決めるだけで、既存行には触りません。ただしPROTECTからCASCADEへ緩めれば、それ以降の削除で連鎖範囲が広がります。逆に締めれば、これまで通っていた削除処理が例外で止まる形です。どちらもデータではなく処理側への影響として検証してください。

## 関連記事

- [Djangoとは？特徴・メリットとMVTの仕組みを入門解説【最新版対応】](https://www.issoh.co.jp/tech/details/3275/)：フレームワーク全体とORMの位置づけです。
- [Django ORMのクエリ設計｜遅延評価と集計の順序・生SQLへ落とす判断](https://www.issoh.co.jp/tech/details/17082/)：状態列で絞り込む側の書き方です。
- [Django migrateの実務｜makemigrationsからの流れと本番適用・巻き戻し](https://www.issoh.co.jp/tech/details/17080/)：制約の張り替えを当てる手順です。
- [DjangoとPostgreSQLの接続設定｜DATABASES定義と接続プール・固有フィールドの判断](https://www.issoh.co.jp/tech/details/17105/)：遅延制約が効く接続側の設定です。
- [Django管理画面の設計：admin標準の範囲とModelAdmin・権限の実装](https://www.issoh.co.jp/tech/details/17078/)：削除確認画面に連鎖対象が出る仕組みです。
- [N+1問題とは？仕組みと検出方法・ORM別の対策【実測クエリ数つき】](https://www.issoh.co.jp/tech/details/4262/)：削除でクエリ本数が膨らむ構図です。

---

出典: [Django on\_deleteの選び方：7種の挙動と6.1のDB\_\*系・削除設計](<https://www.issoh.co.jp/tech/details/17111/>)（株式会社一創）
