Claude CodeでPostgreSQLをパーティション化 — 既存テーブルを止めずに移す手順
PostgreSQLは通常のテーブルをパーティションテーブルに変換できません。旧テーブルを履歴パーティションとして付け替える手順と、Claude Codeに任せる範囲・止める仕組み、EXPLAINでのプルーニング確認をまとめます。
PostgreSQLでは、通常のテーブルをそのままパーティションテーブルに変えることはできません。既存の大きなテーブルを止めずに移すには、パーティションテーブルを新しく作り、旧テーブルを「履歴の1パーティション」として付け替える流れになります。この記事は、その手順の組み立てと、Claude Codeに任せてよい範囲、本番を守る設定、EXPLAINでの確認までを順に書きます。
パーティション化の前に決める3つのこと
最初に決めるのはパーティションキー、方式、パーティション数です。ここをClaude Codeに丸投げすると、もっともらしいが運用に合わない案が出ます。クエリの実態を渡して選ばせるのが筋です。
パーティションキーは、実行されるクエリのWHERE句に最も頻繁に出てくる列が第一候補です。WHERE句がパーティションの境界条件と噛み合えば、不要なパーティションを読み飛ばせるためです。古いデータをまとめて消す運用があるなら、消す単位が1つのパーティションに収まる切り方が向きます。パーティションは短時間で切り離せるからです。
方式は3つです。
| 方式 | 切り方 | 向く場面 |
|---|---|---|
| 範囲(RANGE) | 切り方列の値の範囲で分ける。下限は含み、上限は含まない | 向く場面日付で古いデータを落とす、時系列を月ごとに分ける |
| リスト(LIST) | 切り方値を明示して振り分ける | 向く場面地域など、取りうる値が少なく固定の列 |
| ハッシュ(HASH) | 切り方法(modulus)と余り(remainder)で均等に振り分ける | 向く場面値が増え続ける顧客IDなど、偏りを避けたい列 |
顧客ごとにLISTで分ける設計は、顧客が数社から数千社に増えたときに破綻します。そういう列はHASHで適度な数に分けるほうが堅い、という注意がPostgreSQLの手引きにあります。
パーティション数は多ければよいわけではありません。少なすぎると索引が大きいままでキャッシュに載りにくく、多すぎるとクエリの計画時間とメモリ消費が増えます。1回のクエリで絞り込めずに多数のパーティションが残るほど、この負担は大きくなります。OLTPよりデータウェアハウスのほうが、多めの数を許容しやすいとも書かれています。
また、パーティション化の効果が出るのは、テーブルが物理メモリを超えるほど大きいときが目安です。そこまで大きくないテーブルでは、手間に見合わないことがあります。
既存テーブルは変換できない — 履歴パーティションとして付け替える
通常のテーブルをパーティションテーブルに変える構文はありません。一方で、既存のテーブルをパーティションとして付ける(ATTACH PARTITION)ことと、パーティションを単独のテーブルに戻す(DETACH PARTITION)ことはできます。
この性質を使うと、データを1行もコピーせずに移せます。
データをコピーしない移行の流れ(例)
- 1
パーティションテーブルを新規に作る
旧テーブルと同じ列を持つ親テーブルを
PARTITION BY RANGEで作り、これから先の月のパーティションを用意します。 - 2
旧テーブルに境界のCHECK制約を付ける
「この日付より前の行しかない」という制約を
NOT VALIDで付け、あとから検証します。これで付け替え時の全件スキャンを避けます。 - 3
旧テーブルを履歴パーティションとして付ける
ATTACH PARTITIONで、過去すべてを受け持つ1つのパーティションにします。 - 4
テーブル名を入れ替える
旧テーブルを履歴用の名前に、親テーブルを本来の名前にします。アプリケーションの接続先は変わりません。
付け替えで気をつけるのは、ロックの種類です。ATTACH PARTITIONは親テーブルにはSHARE UPDATE EXCLUSIVEロックを取りますが、付けられる側のテーブルにはACCESS EXCLUSIVEロックを取ります。さらに、付けるテーブルは境界条件を満たすかどうか全件スキャンされ、そのあいだACCESS EXCLUSIVEロックが続きます。これを避けるには、付ける前に同じ条件のCHECK制約を作っておきます。制約が証明してくれるので、スキャンは省かれます。付け替えが終わったら、重複したCHECK制約は削除して構いません。
CHECK制約は NOT VALID で追加すれば、追加時の全件スキャンが省かれます。検証は別のコマンド(VALIDATE CONSTRAINT)で行い、こちらは更新を止めずに走ります。
親テーブルにDEFAULTパーティションを置く場合は、同様に「DEFAULTパーティションに該当行が無い」ことを示す制約も用意しないと、そちらが全件スキャンされます。この記事の手順では置かない前提です。
主キーと一意制約はパーティションキーを含める
見落としやすいのがここです。パーティションテーブルに主キーや一意制約を付けるとき、制約の列にはパーティションキーのすべての列を含める必要があります。パーティションキーに式や関数呼び出しを使うこともできません。各パーティションの索引は、自分のパーティション内でしか一意性を保証できないからです。
結果として、id だけだった主キーは (id, created_at) に変わります。「id だけで一意」という保証はDB側から消え、アプリケーション側の採番(シーケンスなど)に頼ることになります。外部キーで旧テーブルの id を参照している表があると、主キーの付け替えがそこで止まります。着手前に参照関係を洗い出してください。
付ける側のテーブルの索引も重要です。ATTACH PARTITIONは、親の各索引に対して、同等の有効な索引が付け先にあればそれを接続し、無ければ新しく作ります。新しく作るぶんが大きなテーブルへの索引作成になり、ロック時間が伸びます。そこで、付ける前に旧テーブル側へ (id, created_at) の一意索引を CREATE UNIQUE INDEX CONCURRENTLY で作っておき、主キー制約をその索引に差し替えておきます。
切り替えのSQL(例)
次のSQLは、orders テーブル(id、customer_id、created_at、amount)を created_at の月ごとの範囲で分ける想定の一例です。日付やテーブル名は自分の環境に置き換え、必ず本番のコピーで所要時間とロックを測ってから使います。境界は、今後の書き込みが旧テーブルの条件を破らないよう、まだ来ていない日付(ここでは2026-12-01)に置きます。切り替えはその日より前に終える必要があります。
-- 0. 準備(旧テーブル側。更新は止めない)
ALTER TABLE orders ADD CONSTRAINT orders_created_at_notnull
CHECK (created_at IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_created_at_notnull;
ALTER TABLE orders ALTER COLUMN created_at SET NOT NULL; -- 有効なCHECKがあれば全件スキャンを省く
ALTER TABLE orders ADD CONSTRAINT orders_created_at_bound
CHECK (created_at >= '2000-01-01' AND created_at < '2026-12-01') NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_created_at_bound;
CREATE UNIQUE INDEX CONCURRENTLY orders_id_created_at_key
ON orders (id, created_at);
-- 1. 親テーブルと新しい月のパーティション
CREATE TABLE orders_new (LIKE orders INCLUDING DEFAULTS)
PARTITION BY RANGE (created_at);
ALTER TABLE orders_new ADD PRIMARY KEY (id, created_at);
CREATE TABLE orders_2026_12 PARTITION OF orders_new
FOR VALUES FROM ('2026-12-01') TO ('2027-01-01');
CREATE TABLE orders_2027_01 PARTITION OF orders_new
FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');
-- 2. 主キーを新しい索引に差し替える(短時間の排他ロックが入る)
BEGIN;
ALTER TABLE orders DROP CONSTRAINT orders_pkey;
ALTER TABLE orders ADD CONSTRAINT orders_pkey
PRIMARY KEY USING INDEX orders_id_created_at_key;
COMMIT;
-- 3. 履歴パーティションとして付ける
ALTER TABLE orders_new ATTACH PARTITION orders
FOR VALUES FROM ('2000-01-01') TO ('2026-12-01');
ALTER TABLE orders DROP CONSTRAINT orders_created_at_bound;
-- 4. 名前の入れ替え
BEGIN;
ALTER TABLE orders RENAME TO orders_history;
ALTER TABLE orders_new RENAME TO orders;
COMMIT;流れの要点を3つ補足します。
orders_newの列の既定値(idのシーケンスなど)はINCLUDING DEFAULTSで引き継ぎます。シーケンスの持ち主(OWNED BY)は旧テーブルのままなので、旧テーブルを将来外すときに付け替えが必要です。- 付け替えたあとも、旧テーブルは1つの巨大なパーティションとして残ります。古いデータを落としたいなら、この履歴パーティションを切り離す(DETACH PARTITION)か、期間ごとの別テーブルに移し替える作業が別途必要です。
- 切り離しにはCONCURRENTLYを付ける形があります。付けると親テーブルへのロックがSHARE UPDATE EXCLUSIVEに下がりますが、使える条件には制限があるので、実行前にALTER TABLEの手引きを確認してください。
Claude Codeに任せる範囲と、止める仕組み
ここまでのSQLは、Claude Codeが下書きを作り、人間が読んで本番のコピーで試す、という分担にすると事故が起きにくくなります。
計画だけを出させる
パーティション化のように後戻りしにくい変更は、最初にプランモードで始めます。プランモードのClaudeはファイルを読み、計画を提案しますが、承認するまで編集をしません。
claude --permission-mode plan起動後に渡す指示は、決める材料を並べる形にします。例えば次のようにします。
orders テーブル(約2億行)を created_at の月単位でパーティション化したい。
db/queries/ にアプリのクエリがある。WHERE 句に出る列を集計して、
パーティションキー案と、主キー・一意制約・外部キーへの影響を表にして。
SQL はまだ書かず、旧テーブルを履歴パーティションにする手順の計画だけを出して。承認後に、移行SQLと確認用のクエリをファイルに書かせます。実行は人間が行うのが基本です。
規約をCLAUDE.mdに書いておく
毎回のやり取りで伝えるのが面倒な制約は、プロジェクトの CLAUDE.md に書いておきます。
## DBマイグレーション
- 本番DBに直接接続しない。検証はリストア済みのコピー(rehearsal)だけ
- DDL は db/migrations/ にファイルで書く。psql -c での直接実行はしない
- ALTER TABLE は事前に lock_timeout を設定する
- パーティション化では、主キー・一意制約がパーティションキーを含むかを必ず確認する
- 変更後は EXPLAIN でプルーニングが効くことを、対象クエリ全件で確かめる権限とフックで機械的に止める
文章の約束だけでは、守られない場面が残ります。設定ファイルで、psqlの実行に毎回確認を入れ、危険なコマンドを塞ぎます。
{
"permissions": {
"ask": ["Bash(psql *)"],
"deny": ["Bash(dropdb *)"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive-sql.sh"
}
]
}
]
}
}権限のルールは、deny、ask、allowの順に評価され、最初に当たったものが結果を決めます。細かいallowでdenyの例外は作れません。
フックの側は、標準入力にJSONで渡されるコマンド文字列を調べ、終了コード2で拒否する形です。
#!/bin/bash
cmd=$(jq -r .tool_input.command)
if echo "$cmd" | grep -qiE 'DROP TABLE|DETACH PARTITION|TRUNCATE'; then
echo "破壊的なDDLは人間が実行します" >&2
exit 2
fiこのフックが見るのはコマンドラインの文字列だけです。SQLファイルの中身までは検査しないため、psql -f で流すファイルの確認は、人間のレビューが受け持ちます。
EXPLAINでプルーニングが効いているか確かめる
パーティション化の効果は、不要なパーティションを読み飛ばす「プルーニング」で出ます。切り替えたあと、アプリの主要なクエリごとにEXPLAINを取り、読むべき行を決めておきます。
SHOW enable_partition_pruning; -- on が既定値
EXPLAIN
SELECT count(*) FROM orders
WHERE created_at >= DATE '2026-12-01';見るのは、実行計画に登場するパーティションの数です。プルーニングが効いていれば、条件に合う orders_2026_12 などだけが出ます。効いていなければ、すべてのパーティションが Append の下に並びます。PostgreSQLの手引きは、enable_partition_pruning を off にした場合と on の場合の計画を並べて、この差を示しています。
読むときの注意が3つあります。
- プルーニングは、インデックスの有無ではなく、パーティションキーが作る暗黙の制約で決まります。キー列に索引が無くても効きます。
- 実行時に値が決まる条件(パラメータやサブクエリの結果)では、計画時ではなく実行の初期化時や実行中に絞り込まれます。初期化時に除外されたパーティションは、EXPLAINにもEXPLAIN ANALYZEにも出てきません。
- WHERE句がパーティションキーを関数で包んだり、型を変換したりしていると、境界と比較できず全パーティションが残ることがあります。キー列を素のまま比較する書き方に直します。
Claude Codeには、確認の反復を任せられます。クエリを1本ずつ流してEXPLAINの出力を読ませ、パーティションが全部残ったクエリを一覧にさせます。遅いクエリをEXPLAIN ANALYZEで絞り込む手順はClaudeでスロークエリをEXPLAIN ANALYZEで絞る手順にまとめています。
つまずきやすい点
- パーティションに存在しない範囲の行を挿入するとエラーになる。月ごとの分割では、翌月分のパーティションを前もって作る運用が要ります。作成を忘れるとINSERTが失敗するので、パーティション作成を定期ジョブにしておきます。
- 親テーブルにない列をパーティション側に足せない。付ける側のテーブルは、列が親と完全に一致している必要があります。旧テーブルに余分な列があれば、先に取り除きます。
- 親に付ける索引はCONCURRENTLYを指定できない。ロックを避けるには、
CREATE INDEX ON ONLYで親に無効な索引を作り、各パーティションにCONCURRENTLYで索引を作って、親の索引に接続する手順を踏みます。 - ロックの待ち行列に巻き込まれる。長く走るトランザクションの後ろにDDLが並ぶと、後続のクエリまで止まります。DDLの前に
lock_timeoutを設定すると、取れなければ諦めて戻れます。ロックの待ち合いを調べる手順はClaude Codeでデッドロックの原因を調べる手順が参考になります。
まとめ
PostgreSQLでの既存テーブルのパーティション化は、変換ではなく「新しい親を作り、旧テーブルを履歴パーティションとして付ける」作業です。難所はATTACH時のスキャンを避けるCHECK制約の準備と、主キーへのパーティションキーの追加の2点に集まります。Claude Codeは、クエリの集計、計画、SQLの下書き、EXPLAINの反復確認に向きます。一方、本番での実行と、破壊的DDLの判断は人間の側に残すと決めておくほうが安全です。切り替え後に最初にやるのは、主要クエリのEXPLAINでプルーニングを確かめることです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。