2026年5月23日

2026年5月23日

PostgreSQLのパーティショニング設定方法:既存テーブルの移行と月次パーティションの運用

PostgreSQL では、PARTITION BY を使った宣言的パーティショニングで、大きなテーブルを日付や値の範囲ごとの子テーブルに分けられます。分けておくと、検索で関係のないパーティションを読まずに済み、古いデータは DELETE ではなくパーティションごと切り離して消せます。この記事では、ログのように日付で増え続けるテーブルを例に、パーティションの作り方、すでにデータが入っているテーブルの移行、毎月のパーティション作成と古いパーティションの切り離しまでの運用を説明します。

パーティショニングが向いているテーブル

パーティショニングは、テーブルを分ければ何でも速くなる仕組みではありません。効果が出やすいのは次のような条件がそろうときです。

  • 行数が多く、今後も増え続ける(アクセスログ、イベント、計測値、注文履歴など)
  • 検索条件に、パーティションの分け方に使う列(パーティションキー)がほぼ常に入る
  • 古いデータを期間単位でまとめて削除・退避したい

逆に、検索条件にパーティションキーが入らないテーブルでは、すべてのパーティションを調べることになり、分けない場合より遅くなることもあります。パーティションの数が極端に多いと、計画の作成に時間がかかる点にも注意が必要です。まずはインデックスや VACUUM の見直しで解決できないかを確認してから、パーティショニングを検討してください。

なお、WordPress のコアは MySQL と MariaDB を前提にしており、PostgreSQL は公式にはサポートされていません。この記事の内容は、WordPress とは別に PostgreSQL で動かしているアプリケーションや集計用のデータベースを想定しています。

3つの分け方

宣言的パーティショニングでは、分け方を3種類から選びます。

  • RANGE(範囲):日付や連番の範囲で分ける。ログや履歴のように時系列で増えるデータに向く
  • LIST(列挙):地域コードやテナント ID など、決まった値の一覧で分ける
  • HASH(ハッシュ):キーのハッシュ値で均等に分ける。範囲や値に偏りがなく、書き込みを分散したいとき

この記事では、もっとも使われることが多い RANGE で説明します。宣言的パーティショニングは PostgreSQL 10 で導入され、11 以降で HASH やデフォルトパーティション、親テーブルへのインデックス作成などが加わりました。現在サポートされているのは 14 以降のバージョンです(2026年10月現在)。古いバージョンを使っている場合は、使える構文が異なることがあります。

月ごとのパーティションを作る

まず親テーブルを作ります。親テーブル自体にはデータを持たず、どの列で分けるかだけを宣言します。

CREATE TABLE access_log (
    id          bigserial,
    created_at  timestamptz NOT NULL,
    path        text NOT NULL,
    status      smallint NOT NULL,
    user_agent  text,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

連番には bigserial を使っています。パーティションテーブルで IDENTITY 列が使えるようになったのは比較的新しいバージョンからなので、古いバージョンも含めて動く書き方にしました。

ここでつまずきやすいのが主キーです。パーティションテーブルの主キーやユニーク制約には、パーティションキーの列を含める必要があります。PRIMARY KEY (id) だけでは作成時にエラーになるため、(id, created_at) のようにします。

次に、月ごとのパーティションを作ります。FROM の値は含み、TO の値は含まない点を押さえておくと、境界の重なりや隙間を作らずに済みます。

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');

-- どのパーティションにも当てはまらない行の受け皿
CREATE TABLE access_log_default PARTITION OF access_log DEFAULT;

デフォルトパーティションがないと、範囲外の日付の行を挿入したときにエラーになります。一方で、デフォルトパーティションに行が入っていると、後からその範囲のパーティションを作るときに、既存の行を調べる処理やエラーが発生します。デフォルトパーティションは「想定外の行に気づくための受け皿」として置き、行が入っていないかを定期的に確認する運用が向いています。

インデックスは親テーブルに作ると、既存と今後作るすべてのパーティションに自動で作られます。

CREATE INDEX ON access_log (created_at);
CREATE INDEX ON access_log (status, created_at);

パーティションが絞り込まれているか確認する

パーティショニングの効果は、検索のときに不要なパーティションを読まない「パーティションプルーニング」で得られます。EXPLAIN で、実際に対象のパーティションだけが使われているかを確認します。

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM access_log
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-08';

実行計画に access_log_2026_10 だけが出ていれば絞り込まれています。すべてのパーティションが並んでいる場合は、次の点を確認します。

  • WHERE の条件にパーティションキーが入っているか
  • パーティションキーの列に関数をかけていないか(date(created_at) = ... のような書き方は絞り込みに使われにくい。範囲の条件に書き換える)
  • 設定 enable_partition_pruning が off になっていないか(通常は on)

アプリケーションのクエリをパーティションキーの範囲で絞る形に直すことが、パーティショニングの効果を得るうえでもっとも大切です。

既存の大きなテーブルを移行する

すでにデータが入っている通常のテーブルを、そのままパーティションテーブルに変える ALTER はありません。新しいパーティションテーブルを作り、既存のテーブルをパーティションの1つとして取り込むのが、データのコピーを最小限にする方法です。作業の前には pg_dump などでバックアップを取り、ステージング環境で同じ手順を試してから本番で行ってください。

BEGIN;

-- 1. 既存テーブルの名前を変える
ALTER TABLE access_log RENAME TO access_log_old;

-- 2. 同じ列構成でパーティションテーブルを作る(前の章の CREATE TABLE と同じ)
-- 3. 既存テーブルに、取り込む範囲の CHECK 制約を付ける
ALTER TABLE access_log_old
    ADD CONSTRAINT access_log_old_range
    CHECK (created_at >= '2020-01-01' AND created_at < '2026-10-01') NOT VALID;

COMMIT;

-- 4. 制約の検証(テーブル全体を読むが、書き込みは止めにくい)
ALTER TABLE access_log_old VALIDATE CONSTRAINT access_log_old_range;

-- 5. 既存テーブルを古い期間のパーティションとして取り込む
ALTER TABLE access_log ATTACH PARTITION access_log_old
    FOR VALUES FROM ('2020-01-01') TO ('2026-10-01');

ATTACH PARTITION は、取り込むテーブルのすべての行が範囲に収まっているかを調べます。同じ範囲を表す有効な CHECK 制約がすでにあれば、この全件の確認を省略できます。NOT VALID で付けてから VALIDATE する手順にしているのは、制約の検証で長時間ほかの書き込みを止めないようにするためです。

注意点がいくつかあります。

  • 取り込むテーブルは、親テーブルと同じ名前・型の列を持ち、余分な列がないこと。NOT NULL の指定もそろえておく
  • 親テーブルの主キー (id, created_at) に対応するインデックスが既存テーブルになければ、取り込みのときに作られる。行数が多いと時間がかかるので、事前に CREATE INDEX CONCURRENTLY で同じ列のユニークインデックスを作っておくとよい
  • 新しい親テーブルの連番(bigserial のシーケンス)は1から始まるので、setval で既存の最大値より大きい値にしておく。外部キーで参照されているテーブルは、参照の付け替えも検討が要る
  • 名前の変更から取り込み完了までの間、アプリケーションからの書き込みがどちらのテーブルに入るかを考えて、メンテナンス時間を設けるか書き込みを止める

毎月のパーティションを自動で作る

月ごとのパーティションは、その月が来る前に作っておかないと、行がデフォルトパーティションに入る(またはデフォルトがなければ挿入エラーになる)ことになります。cron などで、数か月先までのパーティションを作るスクリプトを定期的に実行するのが手堅い方法です。

-- 翌月分を作る例(cron から psql で毎月実行する)
DO $$
DECLARE
    start_date date := date_trunc('month', now() + interval '1 month');
    end_date   date := start_date + interval '1 month';
    part_name  text := 'access_log_' || to_char(start_date, 'YYYY_MM');
BEGIN
    EXECUTE format(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF access_log FOR VALUES FROM (%L) TO (%L)',
        part_name, start_date, end_date
    );
END $$;

format の %I は識別子、%L はリテラルとして適切に引用符を付けるための指定です。文字列の連結だけで SQL を組み立てないようにします。

作成と削除の管理を拡張機能に任せたい場合は、pg_partman がよく使われています。親テーブルと間隔を登録しておくと、メンテナンス処理で先の期間のパーティション作成や古いパーティションの切り離しを行います。ただし、バージョン 5 系で関数の引数や対応する方式が変わっているなど、バージョンごとの差が大きい拡張機能です。導入する場合は、使うバージョンのドキュメントで手順を確認してください。マネージドサービスでは、使える拡張機能の一覧に含まれているかも事前に確認が必要です。

古いパーティションを切り離して削除する

保存期間を過ぎたデータは、パーティションごと切り離すと、大量の DELETE による負荷や、その後の VACUUM の手間を避けられます。

-- 切り離す
ALTER TABLE access_log DETACH PARTITION access_log_2025_09;

-- 必要なら退避してから削除する
-- pg_dump -t access_log_2025_09 dbname > access_log_2025_09.sql
DROP TABLE access_log_2025_09;

通常の DETACH PARTITION は、短時間ですが親テーブルへのアクセスを待たせるロックを取るので、アクセスの少ない時間帯に実行します。PostgreSQL 14 以降には、ほかの処理を止めにくい DETACH PARTITION ... CONCURRENTLY もあります。ただし、トランザクションブロックの中では実行できず、デフォルトパーティションを持つテーブルでは使えないといった制限があるため、この記事の構成(デフォルトパーティションあり)では使えません。

切り離した時点では通常のテーブルとして残るので、DROP TABLE の前に、退避が必要かどうかを判断できます。

まとめ

  • パーティショニングは、行が増え続け、検索条件にパーティションキーが入り、期間単位で削除したいテーブルに向く
  • 主キーやユニーク制約にはパーティションキーを含める。範囲は FROM を含み TO を含まない
  • EXPLAIN で、対象のパーティションだけが読まれているかを確認する
  • 既存のテーブルは、CHECK 制約を付けてから ATTACH PARTITION で取り込むと全件のコピーを避けられる。作業前にバックアップとステージングでの確認を行う
  • 先の月のパーティションは事前に作り、古い月は DETACH してから削除する

お気軽にご相談ください

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