インデックスは、tableの行を探すための別のdata構造です。読み取りを速くできる一方で、作成容量とINSERT・UPDATE・DELETEの更新コストが増えます。
「このcolumnにindexが必要そう」から始めず、遅いqueryを特定し、実行計画を測り、変更後に同じ条件で再計測します。

最適化の一周
実際に遅いqueryを特定
→ 代表的なparameterとdata量を用意
→ EXPLAINでplan、EXPLAIN ANALYZEで実測
→ WHERE / JOIN / ORDER BYに合うindexを設計
→ 統計を更新し、同条件で再計測
→ read改善とwrite・容量コストを比較
indexが存在しても、PostgreSQLのplannerがSeq Scan、Index Scan、Bitmap Heap Scanなどから安いと推定したplanを選びます。Seq Scanが出たことだけを失敗と判断しません。tableの多くの行を読むqueryでは、順番に読む方が合理的な場合があります。
1. queryと測定条件を固定する
例として、ある顧客の最近の注文を50件表示するqueryを扱います。
SELECT id, created_at, total_amount
FROM orders
WHERE customer_id = 42
AND created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+00'
ORDER BY created_at DESC
LIMIT 50;
同じSQLでも、customer_idごとの注文数、期間、table全体の行数、cache状態、同時負荷で結果は変わります。実際に問題になるparameterと本番に近いdata分布を用意します。
大量投入や大きな変更の直後で統計が古い場合、plannerの行数推定が実態から外れます。通常はautovacuumがANALYZEも行いますが、検証環境で必要なら明示的に更新します。
ANALYZE orders;
2. index作成前を測る
EXPLAINはqueryを実行せず、plannerが選ぶplanと推定値を表示します。
EXPLAIN
SELECT id, created_at, total_amount
FROM orders
WHERE customer_id = 42
AND created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+00'
ORDER BY created_at DESC
LIMIT 50;
ANALYZEを付けるとqueryを実際に実行し、実測時間と実際の行数を追加します。BUFFERSではshared bufferのhitやreadなどを確認できます。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, total_amount
FROM orders
WHERE customer_id = 42
AND created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+00'
ORDER BY created_at DESC
LIMIT 50;
注意: `EXPLAIN ANALYZE`は対象statementを本当に実行します。`INSERT`、`UPDATE`、`DELETE`、function、triggerなどの副作用も起こり得ます。変更系statementを本番で安易に測らず、安全な検証環境とdataで確認してください。transactionでrollbackできない外部副作用やsequenceなどもあります。
3. 実行計画で見る場所
planは子nodeから親nodeへdataを渡すtreeです。node名だけでなく、次を組み合わせて読みます。
| 表示 | 確認すること |
|---|---|
cost=startup..total | plannerの推定コスト。millisecondsではない |
rows | 推定行数と実際の行数が大きく外れていないか |
actual time | 各nodeの1 loopあたりの実測時間 |
loops | nodeが何回実行されたか。総仕事量を読む材料 |
Index Cond | indexの走査範囲を絞る条件 |
Filter / Rows Removed by Filter | 読んだ後に捨てている行が多くないか |
Buffers | blockがcache hitか、readを伴ったか |
Planning Time / Execution Time | 計画と実行にかかった全体時間 |
推定rowsと実測rowsが大きく違う場合は、index追加だけでなく統計やdata分布も確認します。join nodeが何度も呼ばれる場合、actual timeだけでなくloopsを見ます。
4. queryに合わせてB-treeを設計する
例のqueryは、customer_idを等価条件で絞り、created_atの範囲と降順を使います。次の複合B-treeを候補にできます。
CREATE INDEX idx_orders_customer_created_at
ON orders (customer_id, created_at DESC)
INCLUDE (id, total_amount);
customer_idは先頭の等価条件created_atは範囲条件とORDER BYに対応idとtotal_amountは検索順を決めないpayloadとしてINCLUDE
これは例のqueryに対する候補であり、すべての注文queryに最適という意味ではありません。INCLUDEを増やすほどindexが大きくなります。
Index Only Scanが選ばれても、必ずheap accessが0になるわけではありません。PostgreSQLはvisibility mapを確認し、pageがall-visibleでなければheapを参照します。planのHeap Fetchesで確認します。
複合indexの列順に万能ルールはない
PostgreSQLの複合B-treeは、一般に左端columnへの制約があると走査範囲を効率よく絞れます。ただし、後続columnだけの条件でも、plannerがcostに応じてindex scanを選ぶ場合があります。
skip scanはPostgreSQL 18で追加された最適化です。PostgreSQL 17以前では利用を前提にせず、18以降でも常に選ばれるわけではありません。利用中のversionと実際のEXPLAIN (ANALYZE, BUFFERS)で、選択されたplanと読み取ったpageを確認します。
「cardinalityが高いcolumnを必ず先」「等価条件を必ず先」といった短い規則だけで決めず、実際のqueryについて確認します。
- どの条件がindexの走査範囲を狭めるか
ORDER BYとLIMITを同じ順序で処理できるか- 単独queryだけでなく主要なquery群にどう影響するか
- data分布とparameterによってplanが変わらないか
- 既存indexと重複しないか
5. 同じ条件で再計測する
index作成と必要な統計更新の後、同じquery・parameterで再度測ります。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, total_amount
FROM orders
WHERE customer_id = 42
AND created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+00'
ORDER BY created_at DESC
LIMIT 50;
次の変化を記録します。
- scan方式とsort nodeがどう変わったか
- 読んだ行と捨てた行が減ったか
- buffer hit・readが減ったか
- 実行時間が代表parameter群で改善したか
- index容量とwrite latencyが許容範囲か
1回目はdiskから読み、2回目はcache hitになるなど、測定順序だけで時間が変わります。複数回測り、cache状態と同時負荷を揃え、都合のよい1回だけを比較しません。
PostgreSQLの主なindex機能
| 機能 | 候補になる場面 | 注意 |
|---|---|---|
| B-tree | 等価、範囲、並び順 | 既定の方式。まずqueryとの対応を見る |
| GIN | array、JSONB、全文検索など | 更新と容量のcost、operator classを確認 |
| GiST / SP-GiST | range、幾何、検索木に合う型 | extensionやoperator classの仕様に従う |
| BRIN | 物理順序と値が相関する非常に大きなtable | 粗い範囲要約なので用途が異なる |
| 部分index | 安定したpredicateに該当する一部の行 | queryの条件がpredicateを含む必要がある |
| 式index | lower(email)など同じ式で検索する | query側の式と整合させる |
| unique index | 値の一意性を保証する | 性能だけでなくdata制約として設計する |
特殊な方式を名前だけで選ばず、利用するoperator、data型、更新頻度をPostgreSQLの公式資料で確認します。
運用で削除・作成を急がない
pg_stat_user_indexes.idx_scan = 0だけで不要indexとは断定できません。統計のreset時点、待機系での利用、月次処理、制約を支えるindex、plannerが別のindexを選んだ期間などを確認します。
本番で通常のCREATE INDEXを実行するとwriteを妨げる時間があります。CREATE INDEX CONCURRENTLYはwriteを継続しやすくしますが、通常より多くの処理と時間がかかり、transaction block内では実行できず、失敗時にinvalid indexが残る場合があります。変更手順とrollback、監視を準備します。
REINDEXも定期実行する儀式ではありません。破損や実測した肥大化など、理由を特定し、lock・追加容量・CONCURRENTLYの制約を確認して行います。
まとめ
PostgreSQLのindex最適化は、次のloopで進めます。
- 実際に遅いqueryと代表parameterを特定する
- 統計とdata分布を確認する
EXPLAIN (ANALYZE, BUFFERS)でbaselineを残すWHERE、JOIN、ORDER BYに合うindexを一つ設計する- 同条件で再計測する
- read性能とwrite・容量・運用costを比較する
手元で小さなtableを作り、Seq Scanからplanがどう変わるか試す手順は、EXPLAINでインデックスの効きを確認するで扱います。
参考リソース
- PostgreSQL - Indexes
- PostgreSQL - Multicolumn Indexes
- PostgreSQL - Index-Only Scans and Covering Indexes
- PostgreSQL - Using EXPLAIN
- PostgreSQL - EXPLAIN
- PostgreSQL - CREATE INDEX
- PostgreSQL - ANALYZE