動画で読む
まず結論 — DELETE は「消す作業」ではなく「後でやる作業」を増やす行為
結論を先に置きます。Postgres で数千万行を DELETE しても、その瞬間にディスクは空きません。消えたように見える行は dead tuple(不要になった古い行のバージョン)としてファイルに残り続け、あとから vacuum がそれを片付けるまで容量も性能も戻りません。PlanetScale が 2026 年に公開したThe only scalable delete in Postgres is DROP TABLEは、これを「DELETE は work done ではなく work added だ」と表現しています。運用者が持ち帰るべき一点はシンプルです。大量データを定常的に消すなら、削除を行単位の操作ではなく、テーブル単位のメタ操作(DROP TABLE)に設計し直すこと。
私がこれに気づいたのは、ログ系テーブルの保持期間バッチが毎晩じわじわ重くなっていったときでした。DELETE FROM events WHERE created_at < now() - interval '90 days' という、どこにでもある一行です。最初は数秒で終わっていたものが、半年後には深夜に 40 分かかるようになっていました。消す行数は毎晩ほぼ同じなのに、です。
なぜ DELETE はスケールしないのか — MVCC と dead tuple の正体
DELETE が重い根本原因は、Postgres の MVCC(Multi-Version Concurrency Control、複数の行バージョンで同時実行制御を行う仕組み)にあります。DELETE は行を物理的に消すのではなく、「この行は不要」という印を付けるだけです。実体は dead tuple としてページに残り、容量も占めたままになります。
ここから副作用が連鎖します。第一に、読み取りクエリが遅くなります。生きている行を探す途中で死んだ行を読み飛ばす必要があるからです。第二に、インデックスのエントリも即座には消えません。読み手はインデックスを辿った先で「この tuple は本当に生きているか」を毎回判定させられます。第三に、後始末の autovacuum が走り、その分の I/O と CPU を持っていきます。第四に、大きな DELETE はレプリケーションストリームにそのまま流れ、レプリカ側の書き込みを一時的に塞ぎます。
そして決定的なのは、消した分の容量が OS に返らないことです。空いた領域は「次に書き込むとき再利用してよい場所」として印が付くだけで、ディスク使用量の数字はほとんど減りません。私のケースで「行数は同じなのに毎晩遅くなる」が起きていたのは、テーブル全体が bloat(不要領域で膨らんだ状態)で太り続け、スキャン対象のページ数そのものが増えていたからでした。外部キーの cascade を雑に張っていると、一行の削除が連鎖して数 GB を巻き込むこともあります。DELETE は、規模が大きくなるほど割に合わなくなります。
DROP TABLE / TRUNCATE がなぜ速いのか — データ量にほぼ依存しない
対照的に、DROP TABLE と TRUNCATE は消すデータ量にほとんど依存せず一定時間で終わります。これらは行を一行ずつ印付けするのではなく、テーブルに対応するファイルを OS レベルでまるごと外す、ほぼメタデータだけの操作だからです。
効くポイントは三つあります。dead tuple が一切生まれないので vacuum 負債(あとで片付ける宿題)がゼロになること。解放した容量がその場で OS に返ること。そして、必要なのは AccessExclusiveLock(そのテーブルへのアクセスを一時的に占有するロック)を取って共有バッファのヘッダを走査するくらいで、128GB の shared_buffers でも見るのは 1GB 程度のヘッダ領域だけ、という軽さです。実ページを舐めないので、10 億行のテーブルでも 1 万行のテーブルでも所要時間がほぼ変わりません。
ここで運用者として線を引いておきます。TRUNCATE は一瞬とはいえ排他ロックを取ります。深夜の一回きりなら無視できますが、これを 5 分おきに走らせる設計にすると、そのたびにテーブルが止まります。だから速さだけを見て「全部 TRUNCATE にしよう」と飛びつくと、別の場所で詰まります。TRUNCATE は強力ですが、頻度の設計とセットで考えるものです。
一度きりの大掃除 — keep を退避して TRUNCATE する型
「テーブルの 9 割を消して 1 割だけ残したい」という一度きりの大掃除なら、残す行を退避してから TRUNCATE で器ごと空にし、書き戻すのが速いです。DELETE で 9 割を消すより、TRUNCATE で全部消して 1 割を戻すほうが、扱うデータ量が一桁小さくなるからです。
BEGIN;
LOCK TABLE big_table IN ACCESS EXCLUSIVE MODE;
CREATE TEMP TABLE temp_keep_big_table AS
SELECT * FROM big_table WHERE updated_at >= '2026-04-01';
TRUNCATE big_table;
INSERT INTO big_table SELECT * FROM temp_keep_big_table;
COMMIT;
Postgres は DDL もトランザクションに入るので、この一連は失敗したら丸ごとロールバックされます。私が初めてこれを本番で流したときは、退避先を TEMP TABLE ではなく普通のテーブルで作ってしまい、戻し忘れのゴミテーブルを一週間放置しました。TEMP を付けておけばセッション終了で勝手に消えます。小さな話ですが、こういう器の後始末まで含めて「一度きり」の手順です。注意点として、この型は処理中ずっと排他ロックを握るので、書き込みが来るテーブルでは深夜帯やメンテナンスウィンドウに寄せる必要があります。
継続的な削除は「パーティション化して DROP」に寄せる
保持期間で定常的に消すなら、テーブルを日付でパーティション(区画。月や日ごとに物理的に別テーブルへ分ける仕組み)に切り、期限切れの区画を DROP TABLE するのが本命です。こうすると「90 日より古い行を消す」は「先月分の子テーブルを 1 個落とす」になり、行数に依存しない一定コストの操作に変わります。
-- 親テーブルを範囲パーティションで定義
CREATE TABLE events (
id bigint,
created_at timestamptz NOT NULL,
payload jsonb
) PARTITION BY RANGE (created_at);
-- 月ごとの子テーブル
CREATE TABLE events_2026_03 PARTITION OF events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- 保持期間を過ぎたら、その月をまるごと落とすだけ
DROP TABLE events_2026_03;
私はこの切り替えで、冒頭の「深夜 40 分」を「数百ミリ秒」にしました。やったことは毎晩の DELETE を消し、月初に古い子テーブルを DROP TABLE する cron に置き換えただけです。vacuum がログテーブルを延々と追いかける負荷も消えました。乗り換えのコストも正直に書いておきます。既存テーブルのパーティション化は、新しい親を作って中身を移し替える移行が要ります。読み書きを止められないテーブルなら、ここはPgDog で Postgres を透過シャーディングする話で触れたような、接続層を挟んで段階移行する設計と同じ慎重さがいります。一括で消したい誘惑に負けて本番でいきなり張り替えると、巻き込み事故になります。
線引き — DELETE を捨てる話ではない
最後に限界を書きます。これは「DELETE を使うな」という話ではありません。数十行を条件で消すような日常の操作は、素直に DELETE が正解です。パーティション設計のコストを払う価値があるのは、保持期間で定常的に大量行が消えていく、ログ・イベント・時系列のようなテーブルに限られます。
判断軸は二つに絞れます。一つは「消す対象が時間軸で素直に分かれるか」。created_at で割り切れるならパーティション + DROP が綺麗にハマります。注文の論理削除のように、消す行が日付で固まらないものには向きません。もう一つは「頻度と排他ロックのトレードオフ」。TRUNCATE も DROP TABLE も一瞬は占有ロックを取るので、秒間何度も走らせる設計には乗りません。私の結論は、消すことを「行の操作」だと思っているうちは vacuum との追いかけっこが続く、消すことを「テーブルの操作」に格上げできた範囲だけがスケールする、という線引きでした。
よくある質問
- DELETE は二度と使わない方がよいのですか?
- いいえ。数十行を条件で消すような日常の操作は DELETE が正解です。設計を変える価値があるのは、保持期間で定常的に大量行が消えるログ・イベント・時系列テーブルに限られます。
- なぜ DELETE してもディスク使用量が減らないのですか?
- DELETE は行を物理削除せず dead tuple として印を付けるだけだからです。空いた領域は再利用候補としてマークされますが OS には返らず、容量を取り戻すには vacuum や VACUUM FULL が必要になります。
- TRUNCATE と DROP TABLE はどう使い分けますか?
- 残すデータがある一度きりの大掃除は、残す行を退避してから TRUNCATE で器を空にします。保持期間で定常的に消すなら、日付パーティションの古い子テーブルを DROP TABLE する運用が、排他ロックの頻度を抑えられて向いています。