PostgreSQLのパーティションとは、1つの大きなテーブルを「親テーブル」と複数の「子テーブル(パーティション)」に分け、SQLからは1つのテーブルとして扱える仕組みです。 PostgreSQL 10 からは PARTITION BY で宣言するだけで作れる「宣言的パーティショニング」が使え、WHERE 句に合わないパーティションを読み飛ばす「パーティションプルーニング」で検索を速くし、古いデータを DROP TABLE 一発で消せるようになります。

この記事では、レンジ・リスト・ハッシュのDDL(そのまま貼って動く形)から、PostgreSQL 16.13 に600万行を入れて取ったEXPLAINの比較、ATTACH/DETACH・月次パーティションの自動作成・古い月の削除といった運用の手順、さらに PG10〜PG18 でできるようになったことの一覧と導入するかどうかの判断表まで、実行結果つきでまとめます。PG10 時点の解説ではわからない「今のPostgreSQLでの書き方」がこの1本でわかります。

PostgreSQLパーティションの早見表

やりたいこと 書き方(要点) 対応バージョン
日付・連番の範囲で分ける PARTITION BY RANGE (ordered_at) + FOR VALUES FROM (...) TO (...) PG10〜
地域・種別などの値で分ける PARTITION BY LIST (region) + FOR VALUES IN ('a','b') PG10〜
均等に分散したい PARTITION BY HASH (id) + FOR VALUES WITH (MODULUS 4, REMAINDER 0) PG11〜
どこにも入らない行の受け皿 CREATE TABLE ... PARTITION OF 親 DEFAULT(HASHでは不可) PG11〜
2段階で分ける 子テーブルに PARTITION BY ... を付ける(サブパーティション) PG10〜
主キー・一意制約 パーティションキー列を必ず含める PRIMARY KEY (id, ordered_at) PG11〜
インデックス 親に CREATE INDEX すれば全パーティションに自動作成 PG11〜
既存テーブルを組み込む ALTER TABLE 親 ATTACH PARTITION 子 FOR VALUES ... PG10〜
ロックを抑えて切り離す ALTER TABLE 親 DETACH PARTITION 子 CONCURRENTLY PG14〜
古い月を消す DETACH → DROP TABLE(DELETE より大幅に速い) PG10〜
プルーニングの確認 EXPLAIN で対象パーティションだけが出るか、Subplans Removed を見る PG11〜(実行時)
構成の確認 SELECT * FROM pg_partition_tree('親') / psql の \d+ 親 PG12〜

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PostgreSQLパーティション入門|宣言的パーティショニングのDDLと運用」

宣言的パーティショニングの仕組み

親テーブル(パーティション表)はデータを持たず、行はパーティションキーの値に従って子テーブルに振り分けられます。 アプリは親テーブルに INSERT / SELECT / UPDATE / DELETE するだけでよく、子テーブルを意識する必要はありません。

flowchart TD
    A[アプリ: INSERT INTO orders] --> P[orders 親テーブル\nPARTITION BY RANGE ordered_at]
    P -->|2026-05| C1[orders_2026_05]
    P -->|2026-06| C2[orders_2026_06]
    P -->|2026-07| C3[orders_2026_07]
    P -->|範囲外| D[orders_default]
用語 意味
パーティション表(親) PARTITION BY を付けたテーブル。自分ではデータを持たない
パーティション(子) PARTITION OF 親 で作るテーブル。実データを持つ
パーティションキー 振り分けに使う列(または式)
パーティション境界 FOR VALUES ... で指定する各子テーブルの範囲
パーティションプルーニング 条件に合わない子テーブルを計画時・実行時に読み飛ばす最適化

レンジの FROM は含み、TO は含みません(半開区間)。 FROM ('2026-01-01') TO ('2026-02-01') は1月1日0時から1月31日23:59:59.999…までです。月の境界をそのまま並べれば、すき間も重なりもできません。

コピペで使えるDDL(レンジ・リスト・ハッシュ)

以下は PostgreSQL 16.13(Ubuntu、タイムゾーン UTC) で実行した結果です。空のデータベースで上から順に流せば同じ結果になります。

レンジパーティション(月別の注文テーブル)

CREATE TABLE orders (
  order_id   bigint GENERATED ALWAYS AS IDENTITY,
  ordered_at timestamptz NOT NULL,
  member_id  int NOT NULL,
  amount     int NOT NULL,
  PRIMARY KEY (order_id, ordered_at)          -- パーティションキーを含める
) PARTITION BY RANGE (ordered_at);

CREATE TABLE orders_2026_01 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE orders_2026_02 PARTITION OF orders FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE orders_2026_03 PARTITION OF orders FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE orders_2026_04 PARTITION OF orders FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE orders_2026_05 PARTITION OF orders FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE orders_2026_06 PARTITION OF orders FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE INDEX ON orders (member_id);   -- 親に作れば全パーティションに作られる

どのパーティションにも入らない行を INSERT すると、エラーになります。 これがよく検索される no partition of relation found for row です。

INSERT INTO orders (ordered_at, member_id, amount) VALUES ('2026-07-15', 1, 100);
ERROR:  no partition of relation "orders" found for row
DETAIL:  Partition key of the failing row contains (ordered_at) = (2026-07-15 00:00:00+00).

DEFAULT パーティションで受け止める

CREATE TABLE orders_default PARTITION OF orders DEFAULT;
INSERT INTO orders (ordered_at, member_id, amount) VALUES ('2026-07-15', 1, 100);
SELECT tableoid::regclass, * FROM orders;
    tableoid    | order_id |       ordered_at       | member_id | amount
----------------+----------+------------------------+-----------+--------
 orders_default |        2 | 2026-07-15 00:00:00+00 |         1 |    100

tableoid::regclass を SELECT すると、その行がどの子テーブルに入っているかがわかります。 動作確認に便利です(order_id が 2 なのは、失敗した1回目の INSERT でも IDENTITY の番号が進むためです)。

ただし DEFAULT に行が入っている範囲のパーティションを後から作ると、エラーになります。

CREATE TABLE orders_2026_07 PARTITION OF orders FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
ERROR:  updated partition constraint for default partition "orders_default" would be violated by some row

DEFAULT の該当行を別テーブルに退避して削除してから作成し、あらためて親テーブルに INSERT し直す、という手順になります。DEFAULT は「受け皿」ではなく「入ったら気づいて直す場所」として使い、件数を監視しておくと運用が楽になります。

リストパーティション(地域別の会員テーブル)

CREATE TABLE members (
  member_id int NOT NULL, region text NOT NULL, name text
) PARTITION BY LIST (region);

CREATE TABLE members_east  PARTITION OF members FOR VALUES IN ('tokyo','chiba','saitama');
CREATE TABLE members_west  PARTITION OF members FOR VALUES IN ('osaka','kyoto');
CREATE TABLE members_other PARTITION OF members DEFAULT;

INSERT INTO members VALUES (1,'tokyo','A'),(2,'osaka','B'),(3,'sapporo','C');
SELECT tableoid::regclass AS part, * FROM members ORDER BY member_id;
     part      | member_id | region  | name
---------------+-----------+---------+------
 members_east  |         1 | tokyo   | A
 members_west  |         2 | osaka   | B
 members_other |         3 | sapporo | C

ハッシュパーティション(ID で均等に分散)

CREATE TABLE tasks (task_id bigint NOT NULL, title text) PARTITION BY HASH (task_id);
CREATE TABLE tasks_p0 PARTITION OF tasks FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE tasks_p1 PARTITION OF tasks FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE tasks_p2 PARTITION OF tasks FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE tasks_p3 PARTITION OF tasks FOR VALUES WITH (MODULUS 4, REMAINDER 3);

INSERT INTO tasks SELECT g, 't'||g FROM generate_series(1,10000) g;
SELECT tableoid::regclass AS part, count(*) FROM tasks GROUP BY 1 ORDER BY 1;
   part   | count
----------+-------
 tasks_p0 |  2489
 tasks_p1 |  2527
 tasks_p2 |  2530
 tasks_p3 |  2454

ハッシュにはDEFAULTパーティションを作れません。 全ての値がどこかの剰余に必ず入るためです。

ERROR:  a hash-partitioned table may not have a default partition

ハッシュは「古いデータをまとめて消す」用途には向かず、特定キーでの検索や VACUUM の単位を小さくしたいときの分散用と考えると選びやすくなります。

サブパーティション(月 × ログレベル)

CREATE TABLE logs (logged_at timestamptz NOT NULL, level text NOT NULL, msg text)
  PARTITION BY RANGE (logged_at);

CREATE TABLE logs_2026_09 PARTITION OF logs
  FOR VALUES FROM ('2026-09-01') TO ('2026-10-01')
  PARTITION BY LIST (level);                         -- 子をさらに分割

CREATE TABLE logs_2026_09_error PARTITION OF logs_2026_09 FOR VALUES IN ('ERROR');
CREATE TABLE logs_2026_09_other PARTITION OF logs_2026_09 DEFAULT;

SELECT relid, parentrelid, isleaf, level FROM pg_partition_tree('logs');
       relid        | parentrelid  | isleaf | level
--------------------+--------------+--------+-------
 logs               |              | f      |     0
 logs_2026_09       | logs         | f      |     1
 logs_2026_09_error | logs_2026_09 | t      |     2
 logs_2026_09_other | logs_2026_09 | t      |     2

pg_partition_tree()(PG12〜)で階層と葉(実データを持つテーブル)を一覧できます。 サブパーティションはパーティション数が掛け算で増えるので、後述の「数の目安」と合わせて検討してください。

3種類の選び方

方式 向いているデータ 古いデータの削除 DEFAULT 主な使い道
RANGE 日時・連番 DROP で一瞬 作れる 注文・ログ・履歴など時系列
LIST 区分が決まっている値 区分単位で可能 作れる 地域・テナント・種別
HASH 偏りなく分けたいID 不向き 作れない 分散・VACUUM単位の縮小

パーティションプルーニングをEXPLAINで確かめる

ここからは、上の orders に 1〜6月の注文 600万行を入れ、同じデータを持つ非パーティション表 orders_plain と比べます(2コア・メモリ7GB のコンテナ、PostgreSQL 16.13、既定設定)。

INSERT INTO orders (ordered_at, member_id, amount)
SELECT timestamptz '2026-01-01' + (random() * (timestamptz '2026-07-01' - timestamptz '2026-01-01')),
       (random()*100000)::int, (random()*10000)::int
FROM generate_series(1, 6000000);

CREATE TABLE orders_plain AS SELECT * FROM orders;   -- 比較用の普通のテーブル
CREATE INDEX ON orders_plain (ordered_at);
CREATE INDEX ON orders (ordered_at);
VACUUM ANALYZE orders; VACUUM ANALYZE orders_plain;

SELECT tableoid::regclass AS part, count(*), pg_size_pretty(pg_relation_size(tableoid))
FROM orders GROUP BY tableoid ORDER BY 1;
      part      |  count  | pg_size_pretty
----------------+---------+----------------
 orders_2026_01 | 1026602 | 51 MB
 orders_2026_02 |  930431 | 46 MB
 orders_2026_03 | 1026835 | 51 MB
 orders_2026_04 |  994534 | 49 MB
 orders_2026_05 | 1027522 | 51 MB
 orders_2026_06 |  994076 | 49 MB

WHERE で月を指定したとき(プルーニングあり)

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
SELECT count(*), sum(amount) FROM orders
WHERE ordered_at >= '2026-03-01' AND ordered_at < '2026-04-01';
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Gather (actual rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate (actual rows=1 loops=3)
               ->  Parallel Seq Scan on orders_2026_03 orders (actual rows=342278 loops=3)
                     Filter: ((ordered_at >= '2026-03-01 00:00:00+00'::timestamp with time zone) AND (ordered_at < '2026-04-01 00:00:00+00'::timestamp with time zone))
 Planning Time: 0.958 ms
 Execution Time: 87.714 ms

計画に出てくるのは orders_2026_03 だけです。 他の7つのパーティションは計画の段階で外されています。同じ条件を非パーティション表で実行すると次のとおりです。

         ->  Partial Aggregate (actual rows=1 loops=3)
               ->  Parallel Index Scan using orders_plain_ordered_at_idx on orders_plain (actual rows=342278 loops=3)
                     Index Cond: (...)
 Planning Time: 0.179 ms
 Execution Time: 472.253 ms
対象 読み方 実行時間(実測)
パーティション表 3月のパーティションだけを順に全件読む 87.7 ms
非パーティション表 600万行のインデックスから3月分を拾う 472.3 ms

「1か月分をまるごと集計する」ような検索では、該当パーティションを順に読むだけで済むため、インデックスよりも速くなりました。 一方、計画時間は 0.18 ms → 0.96 ms に増えています。パーティション数が増えるほど計画のコストも増えるので、数は必要な分に留めるのがポイントです。

WHERE なしの場合(全パーティションを読む)

EXPLAIN (COSTS OFF) SELECT count(*) FROM orders;
               ->  Parallel Append
                     ->  Seq Scan on orders_2026_07 orders_7
                     ->  Seq Scan on orders_default orders_8
                     ->  Parallel Seq Scan on orders_2026_05 orders_5
                     ->  Parallel Seq Scan on orders_2026_03 orders_3
                     ->  Parallel Seq Scan on orders_2026_01 orders_1
                     ->  Parallel Seq Scan on orders_2026_04 orders_4
                     ->  Parallel Seq Scan on orders_2026_06 orders_6
                     ->  Parallel Seq Scan on orders_2026_02 orders_2

パーティションキーの条件がないと、全パーティションが Append に並びます。 パーティションを使うときは、主な検索に必ずパーティションキーの条件が付くように設計しておくと効果が出ます。

列に関数をかけるとプルーニングされない

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
SELECT count(*) FROM orders WHERE date_trunc('month', ordered_at) = '2026-03-01';
               ->  Parallel Append (actual rows=342278 loops=3)
                     ->  Seq Scan on orders_2026_07 orders_7 (actual rows=0 loops=1)
                     ->  Seq Scan on orders_default orders_8 (actual rows=0 loops=1)
                     ->  Parallel Seq Scan on orders_2026_05 orders_5 (actual rows=0 loops=1)
                           Rows Removed by Filter: 1027522
                     ->  Parallel Seq Scan on orders_2026_03 orders_3 (actual rows=1026835 loops=1)
                     ->  Parallel Seq Scan on orders_2026_01 orders_1 (actual rows=0 loops=1)
                           Rows Removed by Filter: 1026602
                     ...(2・4・6月も同様に全件読んで捨てる)
 Execution Time: 680.330 ms

ordered_at::date = '2026-03-15' も同じく全パーティションを読みました。プルーニングはパーティションキー列そのものに対する条件でしか働きません。 範囲条件に書き換えると、1つのパーティションのインデックスだけを使う計画になります。

-- 直す前: WHERE ordered_at::date = '2026-03-15'
-- 直した後:
EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders WHERE ordered_at >= '2026-03-15' AND ordered_at < '2026-03-16';
 Aggregate
   ->  Index Only Scan using orders_2026_03_ordered_at_idx on orders_2026_03 orders
         Index Cond: ((ordered_at >= '2026-03-15 00:00:00+00'::timestamp with time zone) AND (ordered_at < '2026-03-16 00:00:00+00'::timestamp with time zone))
書き方 プルーニング 実測
date_trunc('month', ordered_at) = '2026-03-01' されない(8パーティション) 680.3 ms
ordered_at >= '2026-03-01' AND ordered_at < '2026-04-01' される(1パーティション) 87.7 ms
ordered_at::date = '2026-03-15' されない ―
ordered_at >= '2026-03-15' AND ordered_at < '2026-03-16' される+インデックス ―

実行時プルーニング(now()・プリペアド文・結合)

値が実行時にしか決まらない条件でも、PG11 以降は実行時にパーティションを外せます。 EXPLAIN では Subplans Removed や (never executed) として表れます。

① now() を使う条件

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders WHERE ordered_at >= now() - interval '200 days';
               ->  Parallel Append (actual rows=1186942 loops=3)
                     Subplans Removed: 2
                     ->  Seq Scan on orders_2026_07 orders_5 (actual rows=0 loops=1)
                     ->  Seq Scan on orders_default orders_6 (actual rows=0 loops=1)
                     ->  Parallel Index Only Scan using orders_2026_03_ordered_at_idx on orders_2026_03 orders_1 (actual rows=544693 loops=1)
                     ->  Parallel Seq Scan on orders_2026_05 orders_3 (actual rows=1027522 loops=1)
                     ...

実行日(2026-10-01)の200日前は3月半ばなので、1月・2月の2つが実行開始時に外されました(Subplans Removed: 2)。

② プリペアド文(汎用プラン)

SET plan_cache_mode = force_generic_plan;   -- 確認のため汎用プランを強制
PREPARE q(timestamptz, timestamptz) AS
  SELECT count(*) FROM orders WHERE ordered_at >= $1 AND ordered_at < $2;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) EXECUTE q('2026-05-01','2026-06-01');
 Aggregate (actual rows=1 loops=1)
   ->  Append (actual rows=1027522 loops=1)
         Subplans Removed: 7
         ->  Index Only Scan using orders_2026_05_ordered_at_idx on orders_2026_05 orders_1 (actual rows=1027522 loops=1)
               Index Cond: ((ordered_at >= $1) AND (ordered_at < $2))

アプリのORMやドライバがプリペアド文を使っていても、パラメータ値でパーティションが外されます。

③ 結合(Nested Loop の内側)

CREATE TABLE campaigns (campaign_id int PRIMARY KEY, start_at timestamptz, end_at timestamptz);
INSERT INTO campaigns VALUES (1, '2026-02-10', '2026-02-11');
ANALYZE campaigns;
SET enable_hashjoin = off; SET enable_mergejoin = off;   -- 確認用に Nested Loop を選ばせる
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT c.campaign_id, count(*) FROM campaigns c
JOIN orders o ON o.ordered_at >= c.start_at AND o.ordered_at < c.end_at
WHERE c.campaign_id = 1 GROUP BY 1;
   ->  Nested Loop (actual rows=33330 loops=1)
         ->  Seq Scan on campaigns c (actual rows=1 loops=1)
         ->  Append (actual rows=33330 loops=1)
               ->  Index Only Scan using orders_2026_01_ordered_at_idx on orders_2026_01 o_1 (never executed)
               ->  Index Only Scan using orders_2026_02_ordered_at_idx on orders_2026_02 o_2 (actual rows=33330 loops=1)
               ->  Index Only Scan using orders_2026_03_ordered_at_idx on orders_2026_03 o_3 (never executed)
               ...(4〜7月、default も never executed)

計画には全パーティションが並びますが、実際に読まれたのは2月だけで、他は (never executed) です。

プルーニングの種類 いつ外すか EXPLAIN での見え方 例
計画時 計画を作るとき 対象外のパーティションが計画に出ない 定数リテラルの WHERE
実行開始時 実行の初期化時 Subplans Removed: N now()、プリペアド文の $1
実行中 ループごと (never executed) Nested Loop の内側、サブクエリの値

なお SET enable_partition_pruning = off; にすると、同じ3月指定のクエリでも全パーティションが計画に並ぶことを確認しました。プルーニングが効かないときは、まずこの設定が off になっていないかを確かめてください(既定は on)。

運用:ATTACH・DETACH・自動作成・削除

古い月の削除は DELETE ではなく DROP

\timing on
BEGIN;
DELETE FROM orders WHERE ordered_at >= '2026-01-01' AND ordered_at < '2026-02-01';
ROLLBACK;
BEGIN;
DROP TABLE orders_2026_01;
ROLLBACK;
DELETE 1026602
Time: 630.143 ms
DROP TABLE
Time: 0.915 ms
方法 102万行の削除にかかった時間 後処理
DELETE ... WHERE 630.1 ms 不要行が残り、VACUUM が必要
DROP TABLE パーティション 0.9 ms ファイルごと消えるので VACUUM 不要

「一定期間を過ぎたらまとめて消す」データこそ、パーティションの効果が最も大きいところです。 削除による不要行(dead tuple)も残らないため、VACUUM の負荷も下がります。

DETACH CONCURRENTLY(PG14〜)

DETACH PARTITION は通常、親テーブルに強いロックを取ります。PG14 以降の CONCURRENTLY を付けると、親テーブルへの読み書きを止めずに切り離せます。 ただし条件が2つあります。

ALTER TABLE orders DETACH PARTITION orders_2026_01 CONCURRENTLY;
ERROR:  cannot detach partitions concurrently when a default partition exists
BEGIN; ALTER TABLE orders DETACH PARTITION orders_2026_02 CONCURRENTLY; ROLLBACK;
ERROR:  ALTER TABLE ... DETACH CONCURRENTLY cannot run inside a transaction block
条件 対処
DEFAULT パーティションがあると使えない DEFAULT を持たない設計にする/一時的に DEFAULT を DETACH してから行う
トランザクションブロック内では使えない 単独の文として実行する(関数・DOブロック内も不可)
途中で中断した場合 ALTER TABLE 親 DETACH PARTITION 子 FINALIZE で完了させる

ATTACH:別に作ったテーブルを組み込む

大量データを先に別テーブルへ投入・インデックス作成し、最後に ATTACH する手順は、親テーブルへの影響を小さくできます。

CREATE TABLE orders_2026_08 (LIKE orders INCLUDING DEFAULTS);
INSERT INTO orders_2026_08 (order_id, ordered_at, member_id, amount)
SELECT g, timestamptz '2026-08-01' + (g % 2592000) * interval '1 second', g % 1000, 100
FROM generate_series(1, 500000) g;

-- 範囲と同じ CHECK 制約を先に付けておくと、ATTACH 時の全件検査を省略できる
ALTER TABLE orders_2026_08 ADD CONSTRAINT orders_2026_08_chk
  CHECK (ordered_at >= '2026-08-01' AND ordered_at < '2026-09-01');

ALTER TABLE orders ATTACH PARTITION orders_2026_08
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

ALTER TABLE orders_2026_08 DROP CONSTRAINT orders_2026_08_chk;   -- ATTACH 後は不要

実測では、インデックスがない状態の初回 ATTACH は 766.6 ms(親のインデックスに合わせて子にインデックスを作るため)、インデックス作成後の ATTACH は 約31〜36 ms でした。時間がかかるのは「範囲の全件検査」と「インデックス作成」なので、どちらも ATTACH 前に済ませておくのがコツです。 PG12 以降、ATTACH 中の親テーブルのロックは SHARE UPDATE EXCLUSIVE に緩和されており、親テーブルの SELECT・INSERT は止まりません。

範囲外の行が入っていると、ATTACH はエラーになります。

ERROR:  partition constraint of relation "orders_2026_09" is violated by some row

範囲が既存パーティションと重なる場合も作成できません。

ERROR:  partition "orders_dup" would overlap partition "orders_2026_08"

月次パーティションを自動で作る関数

「来月のパーティションを作り忘れて INSERT がエラーになる」を防ぐには、数か月先まで先に作っておく関数を定期実行します。

CREATE OR REPLACE FUNCTION create_monthly_partitions(parent regclass, months_ahead int DEFAULT 3)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
  m date := date_trunc('month', current_date)::date;
  part text;
BEGIN
  FOR i IN 0..months_ahead LOOP
    part := format('%s_%s', parent::text, to_char(m + make_interval(months => i), 'YYYY_MM'));
    EXECUTE format(
      'CREATE TABLE IF NOT EXISTS %I PARTITION OF %s FOR VALUES FROM (%L) TO (%L)',
      part, parent, m + make_interval(months => i), m + make_interval(months => i + 1));
  END LOOP;
END $$;

SELECT create_monthly_partitions('orders', 3);   -- 当月+3か月先まで作成
SELECT create_monthly_partitions('orders', 3);   -- 2回目は既存をスキップ
NOTICE:  relation "orders_2026_10" already exists, skipping
NOTICE:  relation "orders_2026_11" already exists, skipping
NOTICE:  relation "orders_2026_12" already exists, skipping
NOTICE:  relation "orders_2027_01" already exists, skipping

IF NOT EXISTS で何度実行しても安全(冪等)にしてあるので、毎日実行しても問題ありません。

古いパーティションを自動で消す関数

CREATE OR REPLACE FUNCTION drop_old_partitions(parent regclass, keep_months int)
RETURNS SETOF text LANGUAGE plpgsql AS $$
DECLARE
  r record;
  cutoff timestamptz := date_trunc('month', now()) - make_interval(months => keep_months);
BEGIN
  FOR r IN
    SELECT c.oid::regclass::text AS part,
           (regexp_match(pg_get_expr(c.relpartbound, c.oid), 'TO \(''([^'']+)''\)'))[1]::timestamptz AS upper_bound
    FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
    WHERE i.inhparent = parent AND c.relpartbound IS NOT NULL
      AND pg_get_expr(c.relpartbound, c.oid) <> 'DEFAULT'
  LOOP
    IF r.upper_bound <= cutoff THEN
      EXECUTE format('ALTER TABLE %s DETACH PARTITION %s', parent, r.part);
      EXECUTE format('DROP TABLE %s', r.part);
      RETURN NEXT r.part;
    END IF;
  END LOOP;
END $$;

SELECT * FROM drop_old_partitions('orders', 5);   -- 当月から5か月より前の月を削除
 drop_old_partitions
---------------------
 orders_2026_04

2026-10-01 時点で5か月前の月初(2026-05-01)より前に終わる orders_2026_04 が削除されました。本番では DROP の代わりに DETACH だけにして、バックアップ(pg_dump)を取ってから消す運用にすると安心です。

定期実行(cron 相当)

PostgreSQL 本体にはジョブスケジューラがないため、OS の cron か拡張機能 pg_cron を使います(以下は本記事の環境では未実行で、書式は各ドキュメントに沿ったものです)。

# OS の crontab(毎日 3:10 に実行)
10 3 * * * psql -d appdb -c "SELECT create_monthly_partitions('orders', 3)"
20 3 1 * * psql -d appdb -c "SELECT * FROM drop_old_partitions('orders', 12)"
-- pg_cron 拡張を入れている場合
SELECT cron.schedule('create-partitions', '10 3 * * *', $$SELECT create_monthly_partitions('orders', 3)$$);

主キー・一意制約・インデックスの制約

親テーブルの主キー・一意制約には、パーティションキーの列を全て含める必要があります。 一意性の検査が各パーティションの中でしか行えないためです。

CREATE TABLE bad (id bigint PRIMARY KEY, created_at date NOT NULL) PARTITION BY RANGE (created_at);
ERROR:  unique constraint on partitioned table must include all partitioning columns
DETAIL:  PRIMARY KEY constraint on table "bad" lacks column "created_at" which is part of the partition key.
制約・機能 ふるまい 回避・対応
主キー・UNIQUE パーティションキー列を含めないと作れない PRIMARY KEY (id, created_at) にする
「id だけで一意」にしたい 親では保証できない IDENTITY/シーケンスで発番し重複を防ぐ。外部キーは複合キーで参照
インデックス 親に作ると全子に作成・新しい子にも自動作成 子だけに追加のインデックスを作ることも可
CREATE INDEX CONCURRENTLY 親テーブルには使えない 各子に CONCURRENTLY で作成 → 親に CREATE INDEX ON ONLY → ALTER INDEX ... ATTACH PARTITION
外部キー(参照される側) PG12〜 可能 参照側も (order_id, ordered_at) の複合で持つ
IDENTITY 列 親経由の INSERT では採番される PG16 では子へ直接 INSERT すると採番されない(下記)
統計情報 autovacuum は親テーブルを ANALYZE しない 親をまたぐ集計が多いなら定期的に ANALYZE orders

PG16 で子テーブルに直接 INSERT すると、IDENTITY が働かずに NULL 違反になりました。

ERROR:  null value in column "order_id" of relation "orders_2026_07" violates not-null constraint

PG16 以前では、INSERT は親テーブル経由で行うのが確実です。 PG17 のリリースノートには「パーティション表が識別列(IDENTITY)を持てるようになった」と記載されており、子への直接 INSERT もふまえた扱いが整理されています。

また、UPDATE でパーティションキーを変えると、行は別のパーティションへ自動で移動します(PG11〜)。

UPDATE orders SET ordered_at = '2026-06-20' WHERE order_id = 10 AND ordered_at < '2026-07-01';
-- 更新前: orders_2026_03 | 10 | 2026-03-02 20:58:48.84269+00
-- 更新後: orders_2026_06 | 10 | 2026-06-20 00:00:00+00

PG10〜PG18 でできるようになったこと

各バージョンのリリースノート(PG16〜18 は公式で確認)をもとに、パーティション関連の主な変更をまとめます。PG10 時点の解説で「できない」とされていたことの多くは、PG11〜PG12 で解消されています。

バージョン 主な追加・変更
PG10 宣言的パーティショニング登場(RANGE・LIST)、サブパーティション
PG11 HASH パーティション、DEFAULT パーティション、親での主キー・一意制約(キー列を含む)、親での CREATE INDEX の自動伝播、UPDATE による行の移動、実行時プルーニング、パーティション表からの外部キー、INSERT ... ON CONFLICT、partition-wise 集計
PG12 パーティション表を参照する外部キー、ATTACH 時のロック緩和、多数パーティション時の性能改善、pg_partition_tree() 等の関数、psql の \dP
PG13 論理レプリケーションでパーティション表を公開(publish_via_partition_root)、BEFORE 行トリガー、partition-wise join の適用範囲拡大
PG14 DETACH PARTITION CONCURRENTLY、パーティション表に対する REINDEX
PG15 パーティション表に対する CLUSTER、順序付きスキャンの適用範囲拡大
PG16 RANGE・LIST のパーティション検索結果のキャッシュによる高速化、pg_dump の --table-and-children などのオプション
PG17 識別列(IDENTITY)、排他制約、テーブルアクセスメソッドの指定、LIMIT の最適化改善
PG18 多数パーティションを扱うクエリの計画効率化、partition-wise join の適用拡大、VACUUM ONLY / ANALYZE ONLY で親だけを処理、パーティション表での NOT VALID 外部キー、UNLOGGED のパーティション表は作成不可に

PG18 で追加された ANALYZE ONLY は、「autovacuum は親テーブルを処理しない」という性質を補うためのものです。 子テーブル全体を再解析せずに親の統計だけを更新できます。

パーティションを使うべきかの判断表

パーティションは「速くする魔法」ではなく、「データを期間や区分で丸ごと扱えるようにする設計」です。 次の表で当てはまる行が多いほど効果が出ます。

観点 パーティションが向いている 普通のテーブル+インデックスで十分
件数・サイズ 1テーブル数千万行以上、サイズがメモリを大きく超える 数百万行以下
削除パターン 「○か月より古いものを消す」を定期的に行う 個別の行を随時消す
主な検索 期間・区分の条件がほぼ必ず付く ID 指定や、キーと無関係な条件が中心
集計 月単位・区分単位でまとめて集計する 全期間を横断する集計が中心
一意性 複合主キー(id+日時)で問題ない id 単独での一意制約が必須
メンテナンス 古い月だけ VACUUM・REINDEX したい 全体を一度に保守できる

パーティション数の目安

目安 考え方
1パーティションあたり 数百万〜数千万行、数百MB〜数十GB 程度
パーティション総数 まずは数十〜数百に収める(月次で10年なら120)
粒度 削除や集計の単位に合わせる(月で消すなら月次)
増やしすぎた場合 計画時間・メモリ・ロック数が増える → 粒度を粗くする

よくあるエラーと対処

エラー文 原因 対処
no partition of relation "orders" found for row 該当範囲のパーティションがない 先のパーティションを自動作成する/DEFAULT を作る
updated partition constraint for default partition ... would be violated by some row DEFAULT に該当範囲の行がある状態で新パーティションを作った DEFAULT から該当行を退避・削除 → 作成 → 再 INSERT
unique constraint on partitioned table must include all partitioning columns 主キー・UNIQUE にパーティションキーがない キー列を含めた複合キーにする
a hash-partitioned table may not have a default partition HASH に DEFAULT を作ろうとした HASH は剰余で全値を網羅するので DEFAULT は不要
partition "..." would overlap partition "..." 範囲が既存と重なる 境界を見直す(TO は含まない)
partition constraint of relation "..." is violated by some row ATTACH 対象に範囲外の行がある 範囲外の行を除いてから ATTACH
cannot detach partitions concurrently when a default partition exists DEFAULT がある状態で CONCURRENTLY DEFAULT を外すか、通常の DETACH を使う
ALTER TABLE ... DETACH CONCURRENTLY cannot run inside a transaction block BEGIN の中で実行した 単独の文として実行する
null value in column "..." violates not-null constraint(IDENTITY 列) PG16 以前で子へ直接 INSERT 親テーブル経由で INSERT する

よくある質問

Q. 既存の大きなテーブルをパーティション表に変えられますか? A. 普通のテーブルを直接パーティション表に変える ALTER はありません。新しい親テーブルを作り、既存テーブルを(範囲と同じ CHECK 制約を付けてから)古い期間のパーティションとして ATTACH し、新しい期間は通常のパーティションで受ける、という手順が停止時間を短くしやすい方法です。

Q. パーティションにすると、どんな検索でも速くなりますか? A. 速くなるのは、パーティションキーの条件で対象を絞れる検索です。本記事の実測でも、月を指定した集計は 472 ms → 88 ms になりましたが、date_trunc() を列にかけると全パーティションを読み 680 ms でした。キー条件のない検索は、むしろ計画時間の分だけわずかに遅くなることがあります。

Q. 月次と日次、どちらで分けるべきですか? A. 「削除する単位」と「よく集計する単位」に合わせるのが基本です。保持期間が数年なら月次、数週間だけ保持するログなら日次が扱いやすくなります。総数が数百を超える場合は粒度を粗くすることを検討してください。

Q. パーティション表の VACUUM はどうなりますか? A. autovacuum は子テーブルを個別に処理します。不要行の多い月だけが処理されるため、1つの巨大テーブルより負荷が分散されます。親テーブルの統計は autovacuum では更新されないため、必要に応じて ANALYZE 親テーブル(PG18 では ANALYZE ONLY)を実行します。

まとめ

  • 宣言的パーティショニングは PARTITION BY RANGE / LIST / HASH と PARTITION OF で作れ、アプリは親テーブルだけを使えばよい
  • 主キー・一意制約にはパーティションキーを含める。範囲外の行は no partition ... found for row になるので、先のパーティションを自動作成しておく
  • プルーニングはキー列そのものへの条件でだけ働く。600万行の実測で、月指定の集計は 87.7 ms、date_trunc() を使うと 680.3 ms
  • now()・プリペアド文・結合でも実行時プルーニングが働き、Subplans Removed や (never executed) で確認できる
  • 古い月の削除は DROP が速い(102万行の DELETE 630 ms に対し DROP 0.9 ms)。DETACH CONCURRENTLY は DEFAULT なし・トランザクション外で使う
  • PG11〜PG12 で多くの制限が解消され、PG17 で IDENTITY・排他制約、PG18 で ANALYZE ONLY などが加わった
  • 導入は「件数」「期間での削除」「キー条件付きの検索」がそろうかで判断する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PostgreSQLパーティション入門|宣言的パーティショニングのDDLと運用」

株式会社RJCでは、PostgreSQLのパーティション設計と運用を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?