# PostgreSQL 宣言的パーティショニング実践（RANGE/LIST/HASH・パーティションプルーニング・ローリングウィンドウ運用・v18対応）

> PostgreSQL の宣言的パーティショニングを本番で正しく使うための実践ガイド。いつ分割すべきか（と、すべきでないか）、RANGE/LIST/HASH の構文、上限が排他という境界の罠、主キー制約のルール、パーティションプルーニングの確認方法、ATTACH/DETACH と時系列のローリングウィンドウ運用、partitionwise join、PostgreSQL 18 の改善までを公式ドキュメントに忠実な実コードで解説します。

- 公開日: 2026-06-21
- 著者: 友田 陽大
- タグ: PostgreSQL, アーキテクチャ設計, パフォーマンス
- URL: https://tomodahinata.com/blog/postgresql-declarative-partitioning-range-list-hash-guide

## 要点

- パーティショニングの目安は『テーブルサイズが物理メモリを超える』規模から。小さい表では複雑さが勝つので、まず索引・VACUUM を尽くす
- RANGE/LIST/HASH の3方式。RANGE の境界は『下限は含む・上限は含まない』。古いデータは DROP/DETACH PARTITION で一瞬で消せる（DELETE と違い VACUUM 負荷もない）
- 主キー・ユニーク制約はパーティションキー列を必ず含める必要がある（これがパーティショニング最大の設計制約）
- プルーニング（enable_partition_pruning 既定ON）で不要パーティションを読み飛ばす。効いているかは EXPLAIN で『読まれたパーティション数』を確認する
- 時系列はローリングウィンドウ運用：来月分を事前作成し、古い月は DETACH/DROP。ATTACH 時は事前 CHECK 制約で全行スキャンを回避。PostgreSQL 18 は多数パーティションの計画・partitionwise join・コスト見積もりを改善

---

「テーブルが数億行になって遅い」「古いログの削除に何時間もかかる」——それは**パーティショニング**の出番かもしれません。1つの巨大テーブルを、ルールに従って物理的に複数の子テーブルに分割し、**プランナーが必要な分割だけを読み**、**古い分割をまるごと一瞬で捨てられる**ようにします。

ただし、パーティショニングは「とりあえず入れる」最適化ではありません。**設計制約**（特に主キー）と**運用の手間**が増えます。この記事は、「いつ・どう分割し、いつ分割すべきでないか」を、PostgreSQL 公式ドキュメントに忠実に、本番運用の手順とともに解説します。[パフォーマンス総論](/blog/postgresql-performance-tuning-production-guide) の §9 で挙げた「物理設計」レイヤーの深掘りです。

> **この記事のルール**：パーティショニングの仕様・構文・設定の既定値・限界・PostgreSQL 18 の変更点は、すべて **PostgreSQL 18 公式ドキュメント（2026年6月時点）** に基づきます。マネージド環境では並列設定等が異なることがあるため、`EXPLAIN` で実際のプルーニングを確認してください。

---

## 1. いつ分割すべきか（と、すべきでないか）

公式が挙げるパーティショニングの**利点**：

- **クエリ性能の劇的改善**——よく使う行が少数のパーティションに集中するとき。索引上位ツリーの代替になり、よく使う部分がメモリに乗りやすくなる。
- **大量更新/集計の高速化**——1パーティションの大部分を読むなら、テーブル全体のランダムアクセスより**シーケンシャルスキャン**が効く。
- **バルク投入・削除がメタ操作になる**——`DROP TABLE` や `DETACH PARTITION` は、一括 `DELETE` より**桁違いに速く**、しかも**`DELETE` が生む VACUUM 負荷を完全に回避**する。
- **コールドデータを安いストレージへ移せる**。

そして**いつ効くか**の目安（公式）：

> これらの利点が割に合うのは、通常**テーブルが非常に大きい場合のみ**。テーブルがパーティショニングの恩恵を受け始める正確な点はアプリ次第だが、**目安はテーブルサイズがデータベースサーバーの物理メモリを超えること**。

> **重要な判断**：数百万行程度なら、まず[索引設計](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide)と[VACUUM](/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide)を尽くす。パーティショニングは**複雑さ（特に主キー制約）と運用コスト**を増やすので、「物理メモリを超える規模」「明確な時系列の破棄パターンがある」場合に絞って導入します。YAGNI——必要になってから。

---

## 2. 3つの方式：RANGE / LIST / HASH

公式の定義と構文。

| 方式 | 分割の仕方 | 典型用途 |
| --- | --- | --- |
| **RANGE** | キーの値の範囲（重複なし）で分割 | 時系列（日付・連番） |
| **LIST** | キーの値を明示列挙して分割 | 地域・テナント・カテゴリ |
| **HASH** | モジュラスと剰余で分割 | 自然な区分がないが均等に分散したい |

### RANGE：時系列の定番

```sql
-- 親テーブル（データは入らない。ルーティングするだけ）
CREATE TABLE measurement (
  city_id  int  NOT NULL,
  logdate  date NOT NULL,
  peaktemp int
) PARTITION BY RANGE (logdate);

-- 月次パーティション。下限は含む・上限は含まない（'2026-07-01' は7月側）
CREATE TABLE measurement_y2026m06 PARTITION OF measurement
  FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

-- どの範囲にも入らない行の受け皿（任意・hash では不可）
CREATE TABLE measurement_default PARTITION OF measurement DEFAULT;
```

**境界の罠**（公式が明記）：「下限 `FROM` は**含む**、上限 `TO` は**含まない**」。例：範囲が `1`〜`10` と `10`〜`20` のとき、値 `10` は**2つ目**に入ります。隣接パーティションで上限＝次の下限にすれば、隙間も重複もありません。上下限なしは `MINVALUE`/`MAXVALUE`。

### LIST：明示列挙

```sql
CREATE TABLE customers (id bigint, region text, name text)
  PARTITION BY LIST (region);

CREATE TABLE customers_apac PARTITION OF customers FOR VALUES IN ('JP','KR','SG');
CREATE TABLE customers_emea PARTITION OF customers FOR VALUES IN ('DE','FR','UK');
CREATE TABLE customers_other PARTITION OF customers DEFAULT;
```

### HASH：均等分散

```sql
CREATE TABLE events (id bigint, payload jsonb) PARTITION BY HASH (id);
-- 4分割（モジュラス4・剰余0〜3）
CREATE TABLE events_p0 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE events_p1 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE events_p2 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE events_p3 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 3);
```

---

## 3. 最大の設計制約：主キーはパーティションキーを含む

これがパーティショニング導入で**最も人を驚かせる**点です。公式の規則：

> パーティション化テーブルに**ユニークまたは主キー制約**を作るには、パーティションキーに式や関数呼び出しを含めず、**制約の列にすべてのパーティションキー列を含めなければならない**。

理由は明快——各パーティションの索引は自分の中の一意性しか保証できないので、「異なるパーティション間で重複しない」ことは**パーティション構造自体**で担保するしかないからです。

```sql
-- ❌ これは失敗する：主キー (id) がパーティションキー logdate を含まない
-- CREATE TABLE m (id bigint PRIMARY KEY, logdate date) PARTITION BY RANGE (logdate);

-- ✅ パーティションキーを主キーに含める（複合主キー）
CREATE TABLE m (
  id      bigint GENERATED ALWAYS AS IDENTITY,
  logdate date NOT NULL,
  PRIMARY KEY (id, logdate)        -- パーティションキー logdate を含む
) PARTITION BY RANGE (logdate);
```

> **設計への影響**：`id` 単独でグローバル一意にしたい場合、パーティショニングと相性が悪い（外部キーの参照先としても `(id, logdate)` 複合になる）。これは「分割するか否か」を決める**最重要のトレードオフ**です。`uuidv7()`（PG18）のような時刻順 UUID を使い、論理的な一意性は UUID で担保しつつ主キーに区分列を含める、といった設計で折り合いをつけます。

その他の限界（公式 §5.12.2.3）：`BEFORE ROW` トリガーで行き先パーティションは変えられない／一時表と永続表をパーティションツリーに混在不可／**通常テーブルとパーティション化テーブルを後から相互変換できない**（最初から設計する）／パーティションキーを更新すると行が別パーティションへ**移動**する。

---

## 4. パーティションプルーニング：効いているか必ず確認

パーティショニングの性能の源泉が**プルーニング**——「クエリに関係ないパーティションを読み飛ばす」最適化です（公式定義どおり）。`enable_partition_pruning` は**既定 ON**。

プルーニングには2種類あります。

- **プラン時プルーニング**：`WHERE logdate >= '2026-06-01'` のように計画段階で値が分かる場合。
- **実行時プルーニング**：`PREPARE` のパラメータやサブクエリ結果、ネステッドループ内側のパラメータなど、**計画時には値が不明**でも実行時に絞る。

```sql
-- プルーニングが効いていれば、関係するパーティションしか計画に出ない
EXPLAIN (ANALYZE)
SELECT count(*) FROM measurement WHERE logdate >= DATE '2026-06-01';
-- → measurement_y2026m06 など該当パーティションだけが現れ、他は読まれない
```

> プラン時にプルーニングされたパーティションは `EXPLAIN`/`EXPLAIN ANALYZE` に**現れません**。逆に「全パーティションが計画に出る」なら、`WHERE` がパーティションキーに触れていない＝プルーニングが効いていないサインです。**必ずパーティションキーで絞るクエリ設計**にします。

（補足：旧来の継承ベース分割は `constraint_exclusion` を使い**プラン時のみ**。宣言的パーティショニングのプルーニングはプラン時＋実行時の両方で効く、より強力な仕組みです。）

---

## 5. ローリングウィンドウ運用：時系列の本命

RANGE パーティショニングの真価は**運用**にあります。「直近Nヶ月だけ保持し、古い月は捨てる」というローリングウィンドウは、`DELETE` ではなく**パーティションの付け外し**で行います。

```sql
-- 古い月をまるごと削除（数百万行でも一瞬。DELETE のような VACUUM 負荷なし）
DROP TABLE measurement_y2025m06;

-- 捨てずにアーカイブしたいなら、独立テーブルとして切り離す
ALTER TABLE measurement DETACH PARTITION measurement_y2025m06;
-- DETACH CONCURRENTLY は親への重いロックを避ける（トランザクション外・DEFAULTパーティションがあると不可）
```

### 新しい月のパーティションを安全に追加する

毎月、来月分のパーティションを**事前に**用意します。空のテーブルを `CREATE TABLE ... PARTITION OF` で作るのが基本（空なのでスキャン不要）。既存テーブルを `ATTACH` する場合は、**事前に一致する CHECK 制約**を付けておくと、公式の言うとおり**取り込み時の全行スキャン（と長いロック）を回避**できます。

```sql
-- 既存テーブルを取り込む場合：先に CHECK を付けると ATTACH 時の全行スキャンを省ける
ALTER TABLE measurement_new
  ADD CONSTRAINT ck CHECK (logdate >= '2026-07-01' AND logdate < '2026-08-01');
ALTER TABLE measurement ATTACH PARTITION measurement_new
  FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
ALTER TABLE measurement_new DROP CONSTRAINT ck;  -- 取り込み後は冗長なので外してよい
```

運用は**冪等な関数**にして、cron（`pg_cron` 等）で月次実行するのが堅牢です。

```sql
-- 指定月のパーティションを「無ければ作る」冪等関数。二重実行しても安全
CREATE OR REPLACE FUNCTION ensure_measurement_partition(target date)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
  start_d date := date_trunc('month', target)::date;
  end_d   date := (date_trunc('month', target) + interval '1 month')::date;
  part    text := format('measurement_y%sm%s', to_char(start_d,'YYYY'), to_char(start_d,'MM'));
BEGIN
  IF to_regclass(part) IS NULL THEN          -- 既に在れば何もしない＝冪等
    EXECUTE format(
      'CREATE TABLE %I PARTITION OF measurement FOR VALUES FROM (%L) TO (%L)',
      part, start_d, end_d);
  END IF;
END;
$$;

-- 毎月、今月と来月分を確保（実行漏れに強い）
SELECT ensure_measurement_partition(current_date);
SELECT ensure_measurement_partition((current_date + interval '1 month')::date);
```

> **可観測性のために**：来月分の作成が漏れると、`DEFAULT` パーティションに落ちるか（用意していれば）、無ければ **INSERT がエラー**になります。「次の2ヶ月分が存在するか」を監視し、欠けていればアラートする運用にしておくと事故を防げます。インデックスは**親に作れば全パーティションへ自動波及**し、後から作るパーティションにも引き継がれます。

---

## 6. partitionwise join / aggregate：既定OFFの理由

パーティション同士の結合・集約を「対応するパーティションごと」に行う最適化が **partitionwise join / aggregate**。**両方とも既定 OFF**です。

理由は公式が明記する**メモリコスト**：これらを有効にすると「`work_mem` で制限されるノード数が**パーティション数に比例して線形に増え**得る。実行時の総メモリ消費が大きく増える可能性があり、計画自体も CPU・メモリ的に大幅に高コストになる」。

```sql
-- パーティション数が少なく、パーティション同士の結合/集約が重いワークロードでのみ検討
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;
```

> 「速くなるなら常時ON」ではありません。**パーティション数が多いと逆効果**になり得るので、対象ワークロードで `EXPLAIN ANALYZE` してから、必要なら**セッション/特定クエリ単位**で有効化します。

---

## 7. PostgreSQL 18 の改善

PG18 はパーティショニングの**運用と計画**を実務的に改善しました（公式リリースノート）。

- **多数パーティションのクエリ計画を効率化**——パーティションが多い設計のプランニングが速くなった。
- **partitionwise join を適用できるケースを拡大し、メモリ使用も削減**——§6 のトレードオフが緩和。
- **パーティションクエリのコスト見積もりを改善**。
- **VACUUM/ANALYZE が子を処理せず親のパーティション化テーブルだけを処理可能に**。
- **パーティション化テーブルに `NOT VALID` 外部キー制約**を許可（段階的な制約追加が可能に）。
- **非btree のユニークインデックスをパーティションキーに利用可能**に。
- **unlogged なパーティション化テーブルは不可に**（一貫性のため）。

> アップグレード観点では、**パーティション数を多く取る設計（多テナント等）ほど PG18 の計画効率化の恩恵**を受けます。

---

## 8. 導入判断チェックリスト

1. **規模は十分か？** テーブルが物理メモリを超える／明確な破棄パターン（時系列）があるか。なければ索引・VACUUM が先。
2. **パーティションキーは決まるか？** ほとんどのクエリの `WHERE` がそのキーで絞れるか（プルーニングの前提）。
3. **主キー制約を許容できるか？** 主キー/ユニークに**パーティションキー列を含める**設計で問題ないか。
4. **方式は適切か？** 時系列=RANGE、明示区分=LIST、均等分散=HASH。
5. **運用を自動化したか？** 来月分の事前作成・古い月の DETACH/DROP を**冪等関数＋スケジューラ**で。
6. **プルーニングを確認したか？** 代表クエリを `EXPLAIN` し、必要なパーティションだけが読まれるか。

---

## 9. まとめ

- パーティショニングは**「物理メモリを超える規模」＋「明確な破棄パターン」**で初めて割に合う。小さい表ではまず索引・VACUUM。
- **RANGE/LIST/HASH**。RANGE の境界は**下限含む・上限含まない**。古いデータは `DROP/DETACH PARTITION` で一瞬（VACUUM 負荷もなし）。
- **主キーはパーティションキーを含める**——これが最大の設計制約。導入前に必ず確認。
- **プルーニング（既定ON）**が性能の源泉。`WHERE` をパーティションキーで絞り、`EXPLAIN` で効果を確認。
- **ローリングウィンドウ**は冪等関数＋スケジューラで自動化。ATTACH は事前 CHECK で全行スキャン回避。
- **partitionwise join/agg は既定OFF**（メモリコスト）。PG18 で多数パーティションの計画・partitionwise join・コスト見積もりが改善。

これで PostgreSQL 本体の性能設計シリーズは一巡です。[パフォーマンス総論](/blog/postgresql-performance-tuning-production-guide) を起点に、[インデックス](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide)・[EXPLAIN](/blog/postgresql-explain-analyze-slow-query-optimization-guide)・[MVCC/VACUUM](/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide)・[JSONB](/blog/postgresql-jsonb-operators-gin-index-design-guide) と合わせて、専用DBを増やさずとも本番の要求に応える PostgreSQL を設計してください。

---

### 参考（PostgreSQL 18 公式ドキュメント）

- [5.12. Table Partitioning](https://www.postgresql.org/docs/18/ddl-partitioning.html)
- [CREATE TABLE（PARTITION BY / FOR VALUES）](https://www.postgresql.org/docs/18/sql-createtable.html)
- [ALTER TABLE（ATTACH / DETACH PARTITION）](https://www.postgresql.org/docs/18/sql-altertable.html)
- [19.7.2. Planner Method Configuration（partitionwise / pruning）](https://www.postgresql.org/docs/18/runtime-config-query.html)
- [E.4. Release 18（パーティショニングの改善）](https://www.postgresql.org/docs/18/release-18.html)
