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 し、ステージングとバックアップを用意して行う

お気軽にご相談ください

お見積りへ お問い合わせへ
▲