開発

Django on_deleteの選び方:7種の挙動と6.1のDB_*系・削除設計

Django on_deleteの選び方:7種の挙動と6.1のDB_*系・削除設計

DjangoでForeignKeyを書くとき、型やフィールド名は迷わないのにon_deleteだけ手が止まる、という経験は多いはずです。ここは「参照先が消えたら参照元をどうするか」という業務仕様をコードで宣言する場所で、Djangoとは?特徴・メリットとMVTの仕組みを入門解説【最新版対応】で扱った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・権限の実装で触れているとおり、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別の対策【実測クエリ数つき】と同じで、削除でも同型の問題が起きると考えてください。

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へ落とす判断の考え方をそのまま使えます。

逆に、セッションや一時ファイルのメタ情報、外部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定義と接続プール・固有フィールドの判断にまとめています。

つまり、データベースに張られているのは参照整合性を守る制約だけで、削除時の動作は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からの流れと本番適用・巻き戻しにまとめてあります。

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の管理下へ載せ替える場面では、この判断が制約の設計とまとめて必要になります。稼働中のシステムを止めずに構造を移す進め方の相談はシステムリプレイス・マイグレーションで承っています。

よくある質問

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へ緩めれば、それ以降の削除で連鎖範囲が広がります。逆に締めれば、これまで通っていた削除処理が例外で止まる形です。どちらもデータではなく処理側への影響として検証してください。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2025.11.04 テックブログ Runpodとは?料金・使い方・支払い方法とGPUクラウドの選び方【2026年10月】
  3. 2026.01.09 コラム 違背実験とは?読み方・意味と具体例、ガーフィンケル原典の数値とエスノメソドロジー
  4. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順

RELATED POSTS 関連記事

目次