データベース

論理削除とは:物理削除との使い分けと一意制約・集計が壊れる問題の実装対策

論理削除とは:物理削除との使い分けと一意制約・集計が壊れる問題の実装対策

論理削除(ソフトデリート)は、行をDELETEせずにdeleted_atなどの列へ削除日時を書き込み、アプリからは消えたように見せる実装です。復元できる安心感から採用されがちですが、入れた直後から一意制約と集計クエリが静かに壊れ始めます。この記事では、論理削除と物理削除の違いと列の持ち方、退会した利用者と同じIDで再登録できなくなる問題をPostgreSQL・SQLite・MySQLで解くSQL、法定の保存期間や個人データの消去との関係、そして論理削除をやめてアーカイブテーブルで代替すべき場面までを、実装の単位で整理します。

まとめ:論理削除は既定にせず、復元か参照の要件がある表だけに限って入れる

結論から書きます。論理削除は全テーブルの既定にしないでください。採用するのは、利用者向けの「ゴミ箱から戻す」機能がある表と、削除後も他の行から参照されて画面に名前を出し続ける必要がある表の2種類に限ります。

採用した表では、次の3つを最初のマイグレーションで同時に入れます。一意制約を「未削除の行だけ」に効く部分一意インデックスへ置き換えること、集計や帳票が参照する未削除だけのビューを用意すること、削除から一定期間を過ぎた行を物理削除するジョブを置くことです。3つ目が無いと、個人データは消えないまま増え続けます。

監査や証跡が目的なら、論理削除ではなく削除時にアーカイブテーブルへ移す物理削除を選んでください。通常のクエリに条件を足さずに済みます。

論理削除と物理削除の違いとdeleted_at列・削除フラグ・状態列の持ち方

物理削除はDELETE文で行が消え、論理削除はUPDATE文で行が残る

物理削除はDELETE文で行そのものをテーブルから取り除きます。バックアップから戻さない限り復元できず、外部キーのON DELETEで指定した参照動作もこのとき動きます。

論理削除はUPDATE users SET deleted_at = now() WHERE id = 1のように列を書き換えるだけで、行は残ります。データベースから見れば単なる更新なので、行数も索引のサイズも減りません。「消えたこと」を知っているのはアプリのコードだけで、そのコードがdeleted_at IS NULLを付け忘れた瞬間に、削除済みの行が画面や集計へ戻ってきます。論理削除の問題のほとんどは、この役割の偏りから生まれます。

真偽値の削除フラグより削除日時を持つdeleted_at列を選ぶ理由

削除済みの印の持ち方は3通りあり、向くデータが異なります。

持ち方 例 分かること 向くデータ
削除フラグ is_deleted boolean 削除済みかどうかだけ 新規採用には勧めない
削除日時 deleted_at timestamp 削除済みかと削除した日時 ゴミ箱型の復元がある表
状態列 status text 退会・休止・販売終了などの業務状態 削除ではなく状態が変わるデータ

新しく作るならdeleted_atを選んでください。削除フラグでは「いつ消したか」が残らず、保存期間を過ぎた行を後から物理削除する条件が書けません。また、未削除の行をNULLで表せるため、後述の部分一意インデックスやMySQLの生成列と相性が良くなります。

見落としやすいのが3行目です。会員の退会、商品の販売終了、取引先の取引停止は「削除」ではなく業務上の状態の変化で、退会者の一覧を出す、販売終了品の過去の受注を表示する、といった画面が後から必ず求められます。こうしたデータに論理削除を当てると、どの画面でもwithTrashedのような例外指定が増えていきます。状態列で表すのが筋です。

Laravel・GORM・Railsのdiscardで異なる論理削除の既定の振る舞い

主要なORMやライブラリは論理削除の仕組みを持っていますが、「通常の検索から削除済みの行を自動で外すか」の方針が分かれます。

ライブラリ 有効化の方法 通常の検索 削除済みを含める・完全に消す
Laravel 13.x SoftDeletes+削除日時列 自動で除外 withTrashed / forceDelete
GORM gorm.DeletedAt型の項目 自動で除外 Unscopedで取得・同指定でDelete
Rails+discard Discard::Modelの読み込み 除外しない(keptを明示) スコープ無しで全件 / destroy

Laravel 13.xのEloquentのドキュメントは、論理削除を有効にしたモデルでは削除済みの行が「すべてのクエリ結果から自動で除外される」と説明しています。この除外を担うのはLaravelのグローバルスコープの仕組みです。列はsoftDeletes()で追加します。GORMのDeleteの説明も同じ考え方で、Deleteはdeleted_atを更新するUPDATE文になり、検索にはdeleted_at IS NULLが自動で付きます。表のGORMの完全削除はUnscoped().Deleteによる操作です。

反対の立場を取るのがRails向けのdiscardです。READMEは、ActiveRecordのメソッドを上書きせず、既定スコープを足すのは通常望ましくないと明記しています。自動除外は書き忘れを防ぐ代わりに、管理画面や集計バッチで削除済みも見たい場面を例外にします。どちらを選ぶにせよ、チーム内で方針を1つに決め、表ごとに混在させないでください。

論理削除を入れたテーブルで一意制約と集計クエリが壊れる仕組み

退会した利用者と同じログインIDで再登録できなくなる一意制約の衝突

最初に壊れるのは一意制約です。削除済みの行が残っているため、制約は削除済みの値とも衝突します。次のSQLをSQLite 3.50系で順に実行すると、最後のINSERTが拒否されます。

CREATE TABLE users (
  id         INTEGER PRIMARY KEY,
  login_id   TEXT NOT NULL UNIQUE,
  deleted_at TEXT
);
INSERT INTO users (id, login_id) VALUES (1, 'tanaka01');
UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE id = 1;  -- 退会(論理削除)

INSERT INTO users (id, login_id) VALUES (2, 'tanaka01');      -- 同じIDで再登録
-- Error: UNIQUE constraint failed: users.login_id

アプリ側のバリデーションがdeleted_at IS NULL付きで重複を調べていると、画面では「使えるID」と表示したのに保存でDBエラーになる、という食い違いまで起きます。deleted_atを一意制約の列に足して(login_id, deleted_at)にする回避策もよく見かけますが、多くのDBは一意インデックスの中でNULL同士を別の値として扱うため、未削除の行(deleted_atがNULL)どうしの重複を止められないのが、この回避策の問題です。PostgreSQLのCREATE INDEXの説明も、既定のNULLS DISTINCTでは一意インデックスに複数のNULLが入ると書いています。

deleted_at IS NULLの書き忘れで削除済みの売上が集計に混ざる

2つ目の破損は気づきにくい種類です。取り消した注文を論理削除している表で、条件を1つ書き忘れるだけで売上が膨らみます。

-- 利用者2の注文:1000円、3000円(取消済み)、500円
SELECT COUNT(*), SUM(amount) FROM orders WHERE user_id = 2;
-- 3 | 4500   ← 取消済みの3000円が混ざる

SELECT COUNT(*), SUM(amount) FROM orders
 WHERE user_id = 2 AND deleted_at IS NULL;
-- 2 | 1500

ORMの自動除外があっても、集計用の生SQL、BIツールからの直接接続、CSV出力のバッチは除外の外側にあります。エラーにならないまま誤った数字が月次の報告書まで届くため、一意制約の衝突より被害が大きくなりがちです。

外部キーのON DELETEが動かず削除済みの親を指す子の行が残る

論理削除はUPDATEなので、外部キーに付けたON DELETE CASCADEやRESTRICTは一切働きません。顧客を論理削除しても、その顧客の注文は「削除済みの顧客を指す生きた行」として残ります。参照整合性の上では正しい状態なので、DBは何も止めません。

子を連れて消したいなら、アプリの削除処理で子にもdeleted_atを書き込む必要があり、復元時は親と同じ時刻に消された子だけを戻す工夫が要ります。親の削除を止めたいなら、削除前に子の件数を調べる処理をアプリに書くことになります。DB側の参照動作で済ませたい関係が多い表ほど、論理削除と相性が悪いと考えてください。参照動作の選び方そのものは外部キーの制約とON DELETEの選び方を解説した記事で扱っています。

部分一意インデックスとビューで論理削除を壊れにくくする実装手順

PostgreSQLとSQLiteで未削除の行だけを一意にする部分一意インデックス

一意制約の衝突は、索引の対象を未削除の行に絞る部分インデックスで解きます。PostgreSQL 18の部分インデックスの章は、WHERE付きのCREATE UNIQUE INDEXで一部の行だけに一意性を課す例を載せています。

-- PostgreSQL
CREATE TABLE users (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  login_id   text        NOT NULL,
  deleted_at timestamptz
);
CREATE UNIQUE INDEX ux_users_login_id_active
  ON users (login_id)
  WHERE deleted_at IS NULL;

-- 稼働中の表で張り替えるときは書き込みを止めずに作ってから旧制約を外す
CREATE UNIQUE INDEX CONCURRENTLY ux_users_login_id_active
  ON users (login_id) WHERE deleted_at IS NULL;
ALTER TABLE users DROP CONSTRAINT users_login_id_key;

SQLiteも同じ構文で動きます。SQLiteの部分インデックスの説明によると、対応は3.8.0(2013年8月26日)以降で、UNIQUE付きの部分インデックスは「表の一部の行の集合に対して一意性を強制する」仕組みです。前のh3のSQLをこの索引に置き換えて実行すると、退会者と同じtanaka01での再登録は通り、未削除のtanaka01をもう1件入れようとした時点でUNIQUE constraint failedになりました。

部分インデックスが検索に使われる条件とクエリのWHERE句の書き方

部分インデックスは、クエリのWHEREが索引の条件を満たすと判定できるときだけ使われます。SQLite 3.50系で実行計画を比べると、差ははっきり出ます。

EXPLAIN QUERY PLAN
SELECT id FROM users WHERE login_id = 'tanaka01' AND deleted_at IS NULL;
-- SEARCH users USING INDEX ux_users_login_id_active (login_id=?)

EXPLAIN QUERY PLAN
SELECT id FROM users WHERE login_id = 'tanaka01';
-- SCAN users

条件を書かないと全件走査です。PostgreSQLの同じ章も、条件はクエリのWHEREの一部と一致している必要があり、照合は実行時ではなく計画時に行われるため、パラメータ化した条件では部分インデックスが使われないと注意しています。deleted_at IS NULLはプレースホルダにせず、SQLへ直接書いてください。ORMの自動除外はこの形で条件を付けるので、通常は問題になりません。

MySQLは生成列にUNIQUEを付けて未削除の行だけを一意にする

MySQLには部分インデックスがありません。代わりに、未削除のときだけ値を持ち、削除済みならNULLになる生成列を作り、そこに一意インデックスを張ります。InnoDBの一意インデックスはNULLを重複とみなさないため、削除済みの行は何件あっても衝突しません。

-- MySQL 8.4系
ALTER TABLE users
  ADD COLUMN active_login_id VARCHAR(64)
    GENERATED ALWAYS AS (IF(deleted_at IS NULL, login_id, NULL)) VIRTUAL,
  ADD UNIQUE INDEX ux_users_login_id_active (active_login_id);

MySQL 8.4系のリファレンスマニュアルは、InnoDBが仮想生成列へのセカンダリインデックスに対応し、それをUNIQUEにできると説明しています。元のlogin_idに付いていた一意制約は、この索引を作った後に外す手順です。GORMで同じことをする場合、DeletedAtを含む複合一意インデックスにはgorm.io/plugin/soft_deleteで日時以外の形式を使う必要があると、前掲のGORMの説明に書かれています。

未削除の行だけを返すビューで集計と帳票の参照先をそろえる実装方針

集計の書き忘れは、参照先を未削除だけのビューに統一して防ぎます。BIツールやCSV出力には元の表ではなくビューへの参照権限だけを渡し、書き忘れが起こりえない構成にするのが確実です。

CREATE VIEW active_orders AS
  SELECT id, user_id, amount
    FROM orders
   WHERE deleted_at IS NULL;

SELECT COUNT(*), SUM(amount) FROM active_orders WHERE user_id = 2;
-- 2 | 1500

注意点が1つあります。PostgreSQLのCREATE VIEWの説明では、このような単純なビューは自動で更新可能で、ビューに対するDELETEは元の表へのDELETEに変換されます。つまりビュー経由の削除は物理削除です。アプリの書き込みは元の表に対して行い、ビューは読み取り専用の権限で配るように分けてください。

削除済みの行を復元するときに起きる一意制約の衝突と復元APIの設計

部分一意インデックスを入れると、今度は復元が失敗する場面が生まれます。退会者tanaka01の行を戻そうとしたとき、同じIDで新しい利用者が既に登録していれば、SQLiteではUPDATE users SET deleted_at = NULLがUNIQUE constraint failedで拒否されました。

この衝突は仕様として扱います。復元APIでは、先に未削除の同じ値が無いかを調べ、あれば「別の利用者が使用中」と業務エラーで返し、DBエラーをそのまま画面に出さないようにします。Laravelのrestore()のような標準メソッドも、この確認はしてくれません。

法令の保存期間と個人データの消去を論理削除で混同しないための設計

帳簿書類の7年保存は利用者に削除させない取引データの設計で満たす

国税庁のタックスアンサーNo.5930(令和8年4月1日現在の法令に基づく)によると、法人の帳簿書類は確定申告書の提出期限の翌日から7年間、欠損金が生じた事業年度は10年間の保存が求められます。「法律で残す必要があるから論理削除にする」という設計判断を時々見かけますが、順序が逆です。

保存義務のある受注・請求・入出金の行は、そもそも利用者の操作で削除させません。取り消しは取消伝票やstatus = 'cancelled'のような状態として記録し、元の行は変えずに残します。論理削除にすると、削除済みの行を誰でもforceDeleteで消せる経路が残るうえ、取消と誤登録の削除が同じ列で区別できない状態になるためです。操作の証跡として何を残すかは、操作ログに残す項目と保存年限を整理した記事が参考になります。

個人情報保護法22条の消去の努力義務と残り続ける退会者の個人データ

逆向きの義務もあります。個人情報保護法の第22条は、個人データを「利用する必要がなくなったときは、当該個人データを遅滞なく消去するよう努めなければならない」と定めています。論理削除した退会者の行は、画面から見えなくても氏名や住所を保有し続けている状態です。漏えいすれば、消したはずの退会者の分まで対象に入ります。

退会者の行を残す理由が「過去の注文画面に名前を出すため」なら、行は残して氏名や連絡先の列だけを消すか、置き換える方法があります。保存期間と削除の方針を業務要件として決める手順は、顧客情報管理システムの保存期間と削除の要件を整理した記事で扱っています。

保存期間を過ぎた論理削除済みの行をLaravelのPrunableで物理削除する

論理削除を採用した表には、削除から一定期間を過ぎた行を物理削除するジョブを同時に入れます。Laravelでこの用途に使えるのはPrunableトレイトです。前掲のドキュメントは、論理削除を使うモデルでは条件に合う行がforceDeleteで完全に消されると説明しています。

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\SoftDeletes;

class Customer extends Model
{
    use SoftDeletes, Prunable;

    // 論理削除から1年を過ぎた行を物理削除の対象にする
    public function prunable(): Builder
    {
        return static::onlyTrashed()
            ->where('deleted_at', '<=', now()->subYear());
    }
}

// routes/console.php
Schedule::command('model:prune')->daily();

// 本番投入前に対象件数だけを確認する
// php artisan model:prune --pretend

MassPrunableに替えると一括のDELETEで速く消せますが、モデルのイベントが発火しないため、削除ログをイベントで取っている設計では記録が抜けます。保持期間の決定から既存テーブルへの部分一意インデックスの導入、削除ジョブの切り替えまでをまとめて見直したい場合は、データベース設計・移行支援の相談窓口で、現状のスキーマの棚卸しから相談できます。

論理削除を採用する条件と、アーカイブテーブルで代替すべき場面の判断

論理削除を採用してよいのは復元機能か削除後の参照表示がある表だけ

論理削除が割に合うのは、次の2つのどちらかに当てはまる表です。

  • 利用者や管理者が画面から削除を取り消せる、ゴミ箱型の復元機能がある(文書、案件、下書きなど)
  • 削除後も他の行から参照され、過去の画面に名前や内容を出し続ける必要がある(担当者、商品マスタなど)

ただし2つ目の多くは、前述のとおり状態列で表すほうが筋の良いデータです。「誤操作に備えて念のため」は採用理由になりません。誤操作への備えはバックアップと、削除前の確認画面の役割です。DB設計のアンチパターンを整理した記事でも、削除済みデータを業務で参照する必要が無いなら論理削除は採用しない、という同じ判断を示しています。

監査が目的ならアーカイブテーブルへ移す物理削除のほうが壊れにくい

「誰が何を消したかを後から確認したい」という監査目的なら、削除時に別の表へ写してから元の表で物理削除します。元の表には生きた行しか残らないため、一意制約も集計も外部キーも通常どおりに働きます。

BEGIN;
INSERT INTO users_archive (id, login_id, deleted_at)
  SELECT id, login_id, CURRENT_TIMESTAMP FROM users WHERE id = 2;
DELETE FROM users WHERE id = 2 RETURNING id, login_id;
COMMIT;

SQLite 3.50系で実行すると、usersから行が消え、users_archiveに同じidとlogin_idが残りました(RETURNINGはSQLite 3.35以降とPostgreSQLで使えます)。写しと削除は必ず同じトランザクションに入れ、片方だけが成功する状態を作らないでください。アーカイブ表には一意制約を付けず、保存期間を過ぎたら表ごとに消せるよう、月や年で分けておくと後の削除が楽になります。

稼働途中から論理削除を入れるときに洗い出す参照箇所と移行の順序

稼働中のシステムに後から論理削除を足すときは、列を足す前に、その表を読むSQLを全数洗い出します。ORMのモデル経由の読み取りは自動除外で済みますが、生SQL、ビュー、帳票、外部システムへの連携は個別にdeleted_at IS NULLを足す必要があります。

  1. 表を参照するSQLをリポジトリ全体とBIツールの定義から検索し、一覧にする
  2. deleted_at列と部分一意インデックス(MySQLは生成列)を追加し、旧い一意制約を外す
  3. 集計・帳票の参照先を未削除のビューへ切り替える
  4. アプリの削除処理をUPDATEへ切り替え、保存期間を過ぎた行を消すジョブを入れる

順序を守る理由は、4を先に出すと、その日から削除済みの行が集計に混ざり始めるからです。1の一覧が作れないほど参照箇所が散らばっている表は、論理削除ではなくアーカイブ方式を選んでください。

論理削除のフラグ・性能・個人情報について実装者からよくある質問

設計と運用でよく出る質問に、判断に使える形で答えます。

論理削除とは簡単に言うと何ですか?

データベースから行を消さずに「削除済み」の印だけを付け、アプリの画面や検索から見えなくする方法です。印には削除日時を入れるdeleted_at列を使うのが一般的です。行が残るので元に戻せる一方、一意制約や集計クエリで削除済みの行が邪魔をする問題が起きます。対になる物理削除はDELETE文で行を取り除く方法で、戻せませんがテーブルは常に生きた行だけになります。

論理削除と物理削除はどちらを使うべきですか?

既定は物理削除にしてください。論理削除を選ぶのは、画面から削除を取り消せる機能がある表と、削除後も過去の画面に表示し続ける必要がある表に限ります。監査のために記録を残したいだけなら、削除時にアーカイブテーブルへ写す物理削除のほうが、通常のクエリに条件を足さずに済むためです。退会や販売終了のような業務状態の変化は、どちらでもなく状態列で表します。

削除フラグはboolean型と日時型のどちらで持つべきですか?

日時型のdeleted_atを勧めます。いつ削除したかが残るので、保存期間を過ぎた行を物理削除する条件を記述できるためです。未削除をNULLで表せるため、PostgreSQLとSQLiteの部分一意インデックスやMySQLの生成列とも組み合わせやすくなります。boolean型のis_deletedでは削除日時を別の列で持つことになり、2列の整合を保つ手間が増えます。

論理削除するとクエリの性能は落ちますか?

削除済みの行が増えるほど、表と索引は大きくなります。影響を抑えるには、検索に使う索引をWHERE deleted_at IS NULL付きの部分インデックスにして、生きた行だけを索引に載せる方法が有効です。このときクエリ側にも同じ条件を直接書く必要があります。削除済みの行が全体の大半を占める表は、アーカイブテーブルへの移送や定期的な物理削除に切り替える時期です。

論理削除した個人情報は削除したことになりますか?

なりません。行が残っている以上、事業者はその個人データを保有し続けています。個人情報保護法の第22条は、利用する必要がなくなった個人データを遅滞なく消去するよう努めることを求めています。退会者の行を残す必要があるなら、氏名や連絡先の列だけを消すか置き換え、保存期間を過ぎた行は物理削除するジョブで消してください。

関連記事

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

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

資料請求

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

  1. 2026.10.07 テックブログ ファインズの不正アクセスと153万件の予約者情報|予約システムを使う店舗の報告と通知の手順
  2. 2026.10.19 テックブログ コミックシーモア不正アクセスで会員情報流出:再開時期・パスワード再設定と運営側のログ点検
  3. 2026.10.16 テックブログ バーガーキング情報漏洩どうしたらいい?約1,118万件の対象確認・補償・退会とメールの真偽
  4. 2026.10.15 テックブログ リゾートトラスト不正アクセス:会員2.6万件・カハラ横浜3.6万件の対象確認と窓口
  5. 2026.10.15 テックブログ 銀のさら不正アクセス:最大650万件の対象確認と窓口、パスワード変更・退会の手順

RELATED POSTS 関連記事

目次