2026年8月24日
2026年8月24日
PostgreSQLのパーティショニング設定方法(宣言的パーティションの作成・運用・既存テーブルの移行)
PostgreSQL のパーティショニングは、ログや履歴のように「時間とともに増え続け、古いものから捨てる」大きなテーブルで効果を発揮します。設定は、親テーブルを PARTITION BY 付きで作り、期間などで区切った子テーブル(パーティション)を追加するだけです。一方で、主キーや一意制約の制限、パーティションを先に作っておく運用など、通常のテーブルにはない注意点があります。この記事では、パーティショニングが向く場面の見極め方、範囲・リスト・ハッシュの選び方、作成の手順、検索が対象のパーティションだけを読んでいるかの確認、古いデータの切り離し、既存の大きなテーブルからの移行までを説明します。
WordPress のコアは MySQL・MariaDB を対象にしており、PostgreSQL は公式にはサポートされていません。この記事は、WordPress と並べて動かすアプリのログや、分析用のデータベースで PostgreSQL を使う場面を想定しています。
パーティショニングが向く場面・向かない場面
パーティショニングは、テーブルを速くする万能の手段ではありません。向いているのは次のような場面です。
- 古いデータをまとめて捨てたい:1か月分を
DELETEすると、大量の行の削除と、その後の VACUUM の負担が発生します。パーティションなら、その月の子テーブルを切り離して削除するだけで済みます。 - 検索の大半が特定の期間や区分に限られる:「直近1週間のログ」のように、検索条件にパーティションの区切りに使う列(パーティションキー)が含まれていれば、関係のないパーティションを読まずに済みます。
- テーブルが非常に大きく、保守作業が重い:VACUUM やインデックスの作り直しを、パーティションごとに分けて行えます。
逆に、数百万行程度で、適切なインデックスがあれば十分に速いテーブルには、管理の手間が増えるだけのことが多いです。また、検索条件にパーティションキーが含まれないクエリは、すべてのパーティションを調べることになり、かえって遅くなる場合があります。まずはインデックスと EXPLAIN で改善できないかを確認してから検討してください。
分け方の種類
PostgreSQL 10 以降の宣言的パーティショニングでは、次の3つの分け方が使えます。
- RANGE(範囲):日時や連番の範囲で分けます。ログや注文履歴など、時系列のデータで最もよく使われます。
- LIST(値の一覧):国、地域、テナントの種類など、決まった値で分けます。
- HASH(ハッシュ):キーのハッシュ値で、指定した数に均等に分けます。データを分散させたいが、自然な区切りがない場合に使います。古いデータをまとめて捨てる用途には向きません。
以下では、もっとも一般的な RANGE(月ごと)の例で説明します。
親テーブルとパーティションを作る
アクセスログを月ごとに分ける例です。
CREATE TABLE access_log (
id bigserial,
logged_at timestamptz NOT NULL,
path text NOT NULL,
status smallint,
PRIMARY KEY (id, logged_at)
) PARTITION BY RANGE (logged_at);
CREATE TABLE access_log_2026_10 PARTITION OF access_log
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE access_log_2026_11 PARTITION OF access_log
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
ポイントは次のとおりです。
- 範囲の上限は含まない:
FROM ('2026-10-01') TO ('2026-11-01')は、10月1日0時ちょうどから、11月1日0時の直前までです。隣のパーティションと同じ値を境目に指定すれば、すき間も重なりもできません。 - 主キーにパーティションキーを含める:パーティションをまたいだ一意性は確認できないため、主キーや一意制約にはパーティションキー(ここでは
logged_at)を含める必要があります。idだけを主キーにしようとするとエラーになります。 - 時刻の型とタイムゾーン:
timestamptzの境目は、接続のタイムゾーン設定で解釈されます。境目をどのタイムゾーンで切るかを決め、作成時の設定をそろえておきます。
親テーブルにはデータは入らず、INSERT INTO access_log ... と書けば、値に応じた子テーブルに自動で振り分けられます。アプリ側のクエリを書き換える必要はありません。
インデックスを作る
PostgreSQL 11 以降では、親テーブルにインデックスを作ると、既存と今後のすべてのパーティションに同じインデックスが作られます。
CREATE INDEX ON access_log (path, logged_at);
ただし、親テーブルに対しては CREATE INDEX CONCURRENTLY を使えません。本番で書き込みを止めたくない場合は、親テーブルに ON ONLY でインデックスの枠だけを作り、各パーティションに CONCURRENTLY で作ってから、親のインデックスに結び付けます。
CREATE INDEX idx_access_log_path ON ONLY access_log (path, logged_at);
CREATE INDEX CONCURRENTLY idx_access_log_2026_10_path
ON access_log_2026_10 (path, logged_at);
ALTER INDEX idx_access_log_path
ATTACH PARTITION idx_access_log_2026_10_path;
-- 残りのパーティションについても同じ手順を繰り返す
すべてのパーティションのインデックスを結び付けると、親のインデックスが有効になります。
対象のパーティションだけを読んでいるか確認する
パーティショニングの効果は、検索のときに関係のないパーティションを除外する「パーティションプルーニング」によるものです。EXPLAIN で確認します。
EXPLAIN
SELECT count(*)
FROM access_log
WHERE logged_at >= '2026-10-01' AND logged_at < '2026-10-08';
実行計画に access_log_2026_10 だけが現れていれば、プルーニングが働いています。すべてのパーティションが並んでいる場合は、次の点を疑います。
- 条件にパーティションキーが含まれていない
- パーティションキーを関数や型変換で加工している(
logged_at::date = ...など) enable_partition_pruningが無効になっている(既定は有効)
プリペアドステートメントのように、値が実行時に決まる場合でも、実行時のプルーニングが働くことがあります。その場合、EXPLAIN ANALYZE の結果で、実行されなかったパーティションが確認できます。
パーティションを先に作っておく
範囲に当てはまるパーティションがない値を INSERT すると、エラーになります。月が変わった瞬間にアプリのログの書き込みが止まる、という事故を防ぐため、翌月や翌々月の分を前もって作っておく仕組みが必要です。
方法は2つあります。
- 定期的なジョブで作る:cron などで、毎月、数か月先のパーティションを
CREATE TABLE IF NOT EXISTS ... PARTITION OF ...で作ります。作成が失敗したら通知されるようにしておきます。 - pg_partman を使う:パーティションの事前作成や古いパーティションの整理を自動化する拡張機能です。マネージドのデータベースサービスでは、使える拡張機能が限られているため、提供されているかを先に確認してください。
範囲外の値を受け止める DEFAULT パーティションも作れますが、DEFAULT にデータが入っていると、新しいパーティションを追加するときに DEFAULT の中身を調べる処理が走ります。使う場合は、DEFAULT が空のままかを監視し、データが入ったら原因を調べる運用にします。
古いパーティションを切り離して削除する
保持期間を過ぎたデータは、パーティションごと切り離します。
-- 切り離す(PostgreSQL 14 以降は CONCURRENTLY で、他の処理を長く止めずに実行できる)
ALTER TABLE access_log DETACH PARTITION access_log_2025_10 CONCURRENTLY;
-- 必要ならバックアップを取ってから削除する
DROP TABLE access_log_2025_10;
切り離したパーティションは、通常のテーブルとして残ります。すぐに削除せず、pg_dump -t access_log_2025_10 で保存してから消す、あるいは別のテーブルスペースに移して保管する、といった運用もできます。CONCURRENTLY での切り離しはトランザクションの中では実行できず、DEFAULT パーティションがあるテーブルでは使えないなどの制限があります。作業の前に、使っているバージョンのドキュメントで条件を確認してください。
既存の大きなテーブルから移行する
すでにデータが入っている通常のテーブルを、そのままパーティションテーブルに変える命令はありません。よく使われるのは、既存のテーブルを最初のパーティションとして取り込む方法です。
BEGIN;
ALTER TABLE access_log RENAME TO access_log_old;
CREATE TABLE access_log (
LIKE access_log_old INCLUDING DEFAULTS
) PARTITION BY RANGE (logged_at);
-- 主キーやインデックスは、既存テーブル側の構成に合わせて別途作成する
COMMIT;
既存のテーブルを ATTACH PARTITION で取り込むと、すべての行が範囲に収まっているかを調べるため、大きなテーブルでは時間がかかります。先に同じ範囲の CHECK 制約を NOT VALID で付け、VALIDATE CONSTRAINT で検証しておくと、取り込みのときの検査を省けます。
ALTER TABLE access_log_old
ADD CONSTRAINT access_log_old_range
CHECK (logged_at >= '2020-01-01' AND logged_at < '2026-10-01') NOT VALID;
ALTER TABLE access_log_old VALIDATE CONSTRAINT access_log_old_range;
ALTER TABLE access_log ATTACH PARTITION access_log_old
FOR VALUES FROM ('2020-01-01') TO ('2026-10-01');
テーブル名の変更から新しいパーティションの作成までの間は、アプリの書き込みが失敗する可能性があります。メンテナンスの時間を取り、事前にバックアップを取ったうえで、ステージング環境で同じ手順を通しておいてください。主キーや外部キー、シーケンスの所有関係など、元のテーブルにあった設定が新しいテーブルに引き継がれているかも確認します。
パーティション数と保守の注意点
パーティションの数が多すぎると、実行計画を立てる時間やメモリの使用量が増えます。どのくらいが適切かはバージョンやクエリによって変わるため、「日ごと」で細かく分ける前に「月ごと」で足りないかを検討します(2026年10月現在の一般的な傾向として、数千を超えるような細分化は慎重に検討すべきとされています)。
また、統計情報の更新(ANALYZE)は親テーブルについては自動では行われないことがあるため、親テーブル全体を対象にしたクエリの計画がずれる場合は、親テーブルに対して手動で ANALYZE を実行します。
まとめ
- パーティショニングは、時系列で増え続け、古いものを捨てる大きなテーブル向け。まずインデックスで改善できないかを確認する
- RANGE・LIST・HASH から選び、主キーと一意制約にはパーティションキーを含める
- 範囲の上限は含まない。EXPLAIN で対象のパーティションだけを読んでいるかを確認する
- 先のパーティションを事前に作り、古いパーティションは DETACH してから削除する
- 既存テーブルの移行は、CHECK 制約で検査を省きつつ ATTACH し、ステージングとバックアップを用意して行う