PostgreSQL EXPLAIN ANALYZEの見方
目次
PostgreSQLで「このSQLが遅い」と言われたら、まず EXPLAIN ANALYZE を実行します。ただし出力を全部読もうとすると、ネストした行とcost/rows/time/loopsの数字で手が止まります。決めておくべきは、どこを見れば原因に当たるかの順番です。出力を前にしてから原因を切り分けるまでの手順を、判断基準とセットで並べます。
実行するコマンドはこの形で固定しておくと楽です。
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT ...;
BUFFERS がないとI/O量が分からず、原因の半分を見落とします。SETTINGS は既定値から変更されたプランナ関連パラメータを出力するので、「開発環境では速いのに本番では遅い」の追跡に使えます。PostgreSQL 18以降は ANALYZE 指定時に BUFFERS が既定で有効になるなど、オプションの既定値はバージョンで変わります。手元のバージョンのドキュメントで確認してください。
結論:EXPLAIN ANALYZEはこの3点から読む(原因の大半はここで分かる)
長い実行計画でも、最初に見るのは次の3点です。
- 一番内側(最も字下げが深い)のノードから、実測時間が大きいものを探す
実行計画はツリー構造で、インデントの深い行が先に実行されます。Execution Time の大半をどのノードが使っているかを特定するのが第一歩です。トップノードの時間は全体の合計なので、そこだけ見ても原因は分かりません。
- 各ノードの
rows=(推定)とactual rows=(実測)の比を見る
Index Scan on orders (cost=0.43..8.45 rows=1 width=120)
(actual time=0.021..3.140 rows=12500 loops=1)
推定1行に対して実測12,500行。この桁違いのズレがプランナの誤判断を生み、上位ノードの結合方式の選択までまるごと狂わせます。遅いSQLで最も多いパターンです。
loops=が大きいノードを探す
loops=1000 のように繰り返されるノードはNested Loopの内側です。そこの actual time は1回あたりの平均なので、実コストは actual time の上限値 × loops で概算します。1回0.3msでもloops=50,000なら15秒です。
この3点で当たりが付かなければ、ディスクソート(external merge Disk)、Hashのバッチ分割、純粋なI/O待ちを疑います。後半で扱います。
出力の読み方の原則:推定と実測のズレを見る
EXPLAIN ANALYZEの価値は、プランナの見積もり(cost, rows)と実際に起きたこと(actual time, actual rows)を並べて比較できる点にあります。プランナは統計情報(pg_statistic)から行数を推定し、その推定行数で結合方式やインデックス利用を決めます。推定が外れれば、その先の選択も連鎖して外れます。だから最初に見るのは推定と実測のズレです。
cost・rows・actual time・loopsがそれぞれ示すもの
| 項目 | 意味 | 見るポイント |
|---|---|---|
cost=A..B |
A=最初の1行を返すまでの推定コスト、B=全行返し終わるまでの推定コスト | 単位は「ページ読み取り1回」を1.0とした相対値。秒ではない |
rows= |
このノードが返すと推定した行数(loops=1回あたり) | actual rows との比が重要 |
width= |
1行あたりの推定バイト数 | 大きすぎる場合は不要なカラムをSELECTしている |
actual time=X..Y |
X=最初の1行が返るまで、Y=全行返し終わるまでの実測ミリ秒 | loops>1なら1回あたりの平均 |
actual rows= |
実際に返した行数(loops>1なら平均) | 推定とのズレを確認 |
loops= |
このノードが実行された回数 | Nested Loopの内側で膨らむ |
cost は秒ではないので、「costが10万だから遅い」とは言えません。seq_page_cost=1.0、random_page_cost=4.0、cpu_tuple_cost=0.01 などから算出される相対値で、候補プランの比較にだけ使われます。SSDやEBS gp3ではランダムアクセスのペナルティが実態より大きく見積もられがちなので、random_page_cost を下げてインデックススキャンを選びやすくする調整はよく使われます。インスタンス全体に効くので、影響範囲を確認してから変更してください。
actual rows が平均値である点は見落としやすい。loops=100でactual rows=1なら、合計100行です。バージョンによっては平均値が小数で表示されるため、整数に丸められているかどうかもバージョン差として意識しておきます。
ノードの実行時間は「自分+子ノード」の累計で表示される
ここを知らないと時間の読み方を間違えます。
Hash Join (actual time=120.500..980.200 rows=50000 loops=1)
-> Seq Scan on orders (actual time=0.015..300.100 rows=500000 loops=1)
-> Hash (actual time=119.800..119.810 rows=1000 loops=1)
-> Seq Scan on customers (actual time=0.010..60.200 rows=1000 loops=1)
Hash Joinの980msには、子のSeq Scan(300ms)とHash構築(120ms)が含まれます。ノード自身が消費した時間は累計から子の累計を引いた差分で、この例なら 980 − 300 − 120 = 560ms 程度がHash Join自身の処理時間です。
評価の手順は、末端の行から上へ累計時間をたどり、親と子の差が大きいノードを探して、それを原因と見ます。loops>1 のノードは表示値が1回あたりなので、親の累計と比べるときは actual time × loops に換算してから当てます。
Parallel系ノード(Parallel Seq Scan など)では、Workers Planned と Workers Launched も確認します。LaunchedがPlannedより少なければ max_parallel_workers が枯渇しており、プランナの想定より遅くなります。
原因の切り分け手順:上から順に確認する
ここからは症状別の対処です。上から順に確認すれば、だいたい途中で原因に当たります。
手順1:推定行数と実測行数が桁違いにズレていないか
判断基準は、推定と実測の比が10倍以上(どちら向きでも)。この場合は統計情報かSQLの書き方に問題があります。原因は主に3つです。
(a) 統計情報が古い
大量INSERT/DELETE直後や、autovacuumが追いついていないテーブルで起きます。まず対象テーブルの最終ANALYZE時刻を確認します。
SELECT relname, last_analyze, last_autoanalyze, n_live_tup, n_dead_tup, n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'orders';
n_mod_since_analyze が n_live_tup に対して無視できない割合なら、手動ANALYZEで改善するか試します。
ANALYZE VERBOSE orders;
なお、スナップショットから復元した環境では統計情報の扱いに注意が必要です(Aurora PostgreSQLスナップショット復元と統計情報)。
(b) 列の分布が偏っていて、デフォルトの統計精度では足りない
default_statistics_target は既定で100です。カーディナリティが高い列や分布が極端に偏る列は、列単位でターゲットを上げます。
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
(c) 複数列の相関をプランナが知らない
WHERE pref = '東京都' AND city = '渋谷区' のように列同士が強く相関する場合、プランナは独立と仮定して選択率を掛け算するため、推定行数が実際より極端に小さくなります。PostgreSQL 10以降は拡張統計で対処できます。
CREATE STATISTICS stat_orders_pref_city (dependencies, ndistinct)
ON pref, city FROM orders;
ANALYZE orders;
Rows Removed by Filter が大きいときは、スキャンで読んだ行の大半を捨てています。
Seq Scan on orders (actual time=0.020..850.300 rows=120 loops=1)
Filter: (status = 'pending'::text)
Rows Removed by Filter: 4999880
500万行読んで使ったのは120行。条件列へのインデックス追加、または部分インデックスが有効な典型です。
CREATE INDEX CONCURRENTLY idx_orders_pending ON orders (created_at) WHERE status = 'pending';
本番では CONCURRENTLY を付けてロックの影響を避けます。代わりに作成時間はかかり、失敗すると INVALID なインデックスが残るので後始末が必要です。
手順2:Nested Loopのloopsが膨らんでいないか
Nested Loopは外側の1行ごとに内側を1回実行する結合方式です。外側が数十行程度で内側をインデックスで引けるなら最速ですが、外側の行数の推定が外れると極端に遅くなります。
Nested Loop (cost=0.86..45.20 rows=1 width=80) (actual time=0.050..28500.100 rows=98000 loops=1)
-> Index Scan on orders (rows=1) (actual rows=98000 loops=1)
-> Index Scan on order_items (rows=1) (actual time=0.280..0.290 rows=1 loops=98000)
内側は1回0.29msでも、loops=98,000で合計約28秒。推定1行が実測98,000行だったため、プランナがNested Loopを選びました。原因はNested Loopそのものではなく、手順1の推定ミスです。
対処の優先順位は、外側の推定行数を正す(ANALYZE、拡張統計、条件の書き換え)、内側の結合キーに適切なインデックスがあるか確認する、どうしても直らない場合に限りセッション単位で結合方式を誘導する、の順になります。
-- 原因調査・緊急回避の用途に限る。恒久設定にはしない
SET enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET enable_nestloop;
enable_nestloop = off で速くなれば、Hash JoinやMerge Joinのほうが適切なのに選ばれていない、という裏付けになります。これは診断用のスイッチなので、恒久対応は統計情報とインデックスで行います。
手順3:SortやHashがディスクに溢れていないか(external merge / Batches)
work_mem に収まらないソートやハッシュは一時ファイルに書き出され、その事実が実行計画に明示されます。
ソートの場合はこう出ます。
Sort (actual time=1200.300..1450.800 rows=800000 loops=1)
Sort Key: created_at DESC
Sort Method: external merge Disk: 98304kB
Sort Method が quicksort Memory: や top-N heapsort Memory: ならメモリ内で完結しています。external merge Disk: が出たらディスクに溢れており、Disk: 98304kB は約96MBの一時ファイルを書いたことを意味します。
ハッシュの場合は次のとおり。
Hash (actual time=820.100..820.110 rows=2000000 loops=1)
Buckets: 65536 Batches: 32 Memory Usage: 3073kB
Batches: 1 ならメモリ内、2以上はバッチ分割でディスク書き出しが発生しています。
対処は work_mem をセッション単位で引き上げて再測定するか、ソート対象の行数そのものを減らすことです。後者はWHEREで絞る、LIMIT と一致する順序のインデックスを作ってSortノード自体を消す、といった方法になります。
SET work_mem = '128MB';
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET work_mem;
注意点として、work_mem は接続単位ではなくソートやハッシュの処理単位に割り当てられます。1クエリ内に複数のSortやHashがあればその分だけ消費し、さらにパラレルワーカーごとにも消費します。postgresql.conf でグローバルに大きくするとメモリ不足のリスクが上がるため、重いバッチだけロール単位やセッション単位で上げるのが安全です。
ALTER ROLE batch_user SET work_mem = '256MB';
ORDER BY created_at DESC LIMIT 50 のようなクエリでSortが出ているなら、(created_at DESC) のインデックスを作ればSortノードが消えて Index Scan Backward だけになります。効果がいちばん大きい対処です。
手順4:インデックスがあるのにSeq Scanが選ばれていないか
前提として、Seq Scanは常に悪ではありません。テーブルの大部分を読むならSeq Scanのほうが速く、プランナの判断が正しいケースがほとんどです。疑うのは「数行しか返さないのにSeq Scan」のときだけ。
確認手順はこうです。
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET enable_seqscan;
インデックススキャンが選ばれて実際に速くなったなら、プランナのコスト見積もりが実態と合っていません。原因の候補は次のとおりです。
- 統計情報が古い(手順1へ戻る)
random_page_costが実際のストレージ性能に対して高すぎる。SSDベースなら1.1〜2.0程度に下げる構成がよく使われます。インスタンス全体に影響するので、検証のうえで変更します- 型の不一致でインデックスが使えない。
varchar列に数値リテラル、timestamp列にdateを関数なしで比較、など - 左辺に関数や演算が付いている。
WHERE date(created_at) = '2026-10-12'はインデックスを使えません。WHERE created_at >= '2026-10-12' AND created_at < '2026-10-13'に書き換えるか、式インデックスを作ります - LIKEの前方一致以外。
'%abc%'はB-treeでは使えないので、pg_trgmのGINインデックスを検討します - 照合順序(collation)の不一致。
LIKE 'abc%'でインデックスを使うには、Cロケールかtext_pattern_opsのインデックスが必要です
Bitmap Heap Scan が出ているときは、ヒープへのアクセス方式も見ます。
Bitmap Heap Scan on orders (actual time=...)
Recheck Cond: (status = 'pending'::text)
Heap Blocks: exact=12000 lossy=350000
lossy が大きいのは、work_mem が足りずビットマップがページ単位に粗くなった状態です。Recheckのコストが膨らむため、work_mem の引き上げか条件の絞り込みを検討します。
ここまでの切り分けはPostgreSQL固有の挙動が絡むため、Oracleから移行してきた環境では「同じSQLなのに遅い」という形で現れることがあります。ヒントによる制御の違いや統計情報の取得タイミングの違いについては、Oracle から PostgreSQL 移行でハマるポイントも参考になります。
BUFFERSまで踏み込む:I/O律速かCPU律速かを見分ける
BUFFERS を付けると、各ノードが触ったバッファ数が出ます。
Seq Scan on orders (actual time=0.030..2800.500 rows=5000000 loops=1)
Buffers: shared hit=1024 read=610000
shared hit は共有バッファ(shared_buffers)上で見つかったブロック数、つまりメモリヒットです。shared read は共有バッファになくOSやストレージから読んだブロック数で、こちらがミス。shared dirtied はこのクエリが変更したブロック数、shared written はバッファから書き出したブロック数を表します。temp read/written は一時ファイルの読み書きで、手順3のディスク溢れと対応します。local hit/read は一時テーブル用のローカルバッファです。
1ブロックは既定で8KBなので、read=610000 は約4.7GBをストレージから読んだことになります。ここで判断が分かれます。
I/O律速なら、shared read が大きく actual time もそれに比例して大きくなります。対処は読むブロック数を減らすこと、つまりインデックス追加、不要カラムの削除、パーティショニング、VACUUMやpg_repackによるテーブル肥大化の解消です。shared_buffers を増やしてキャッシュヒット率を上げるアプローチもありますが、まずは読む量を減らすほうが効果が確実です。
CPU律速なら、Buffers が小さいのに actual time が大きくなります。関数呼び出し、型変換、大量行のソートや集約、正規表現、JSONBの展開などが候補です。VERBOSE の Output: に重い式が並んでいないか確認します。
readの実時間まで見たい場合は track_io_timing を有効にします。
SET track_io_timing = on;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
有効にすると I/O Timings: shared read=2100.456 ms のような行が追加され、実行時間のうちI/O待ちが何msだったかを直接読めます。計測自体にオーバーヘッドがあるため、グローバルに有効化する前に pg_test_timing でタイマー精度とコストを確認する運用が推奨されています。
Execution Time と各ノードの合計が合わない場合は、Planning Time とJITを見ます。
Planning Time: 0.350 ms
JIT:
Functions: 42
Timing: Generation 5.100 ms, Inlining 20.300 ms, Optimization 180.500 ms, Emission 95.200 ms, Total 301.100 ms
Execution Time: 350.800 ms
総コスト推定が jit_above_cost(既定10万)を超えるとJITコンパイルが走ります。短時間で終わるクエリでコンパイル時間が支配的になることがあるため、該当する場合は SET jit = off で比較してみてください。
OSS-DB Silver 対策テキスト(OSS教科書)
PostgreSQLの仕組みを体系的に学ぶなら、OSS-DB Silverの公式対応テキストが近道です。資格を取らなくても基礎固めに役立ちます。
よくある質問
EXPLAIN ANALYZEは本番環境で実行しても安全?
EXPLAIN ANALYZE はクエリを実際に実行します。SELECTなら結果が破棄されるだけですが、実行時間とI/Oは本当に消費するため、重いクエリを本番で流せばその分の負荷がかかります。実行中はテーブルにACCESS SHAREロックが取られるので、DDLと競合する可能性もあります。
安全に扱う実務的な対策は、SET statement_timeout = '30s'; を先に設定して暴走を防ぐ、実行計画だけ見たいなら ANALYZE なしの EXPLAIN にする、更新系は後述のトランザクションとROLLBACKで行う、可能ならリードレプリカで試す、の4点です。レプリカは hot_standby_feedback やキャッシュ状態の違いで結果が変わる点に注意してください。
EXPLAINとEXPLAIN ANALYZEの違いは?
EXPLAIN は実行せず、プランナが選んだ計画と推定値だけを表示します。EXPLAIN ANALYZE は実際に実行して実測値を併記します。
推定と実測のズレを見るのがチューニングの核心なので、原因調査では原則 ANALYZE を付けます。EXPLAIN 単体が有効なのは、実行が危険なほど重いクエリの計画形状だけ確認したいとき、インデックス追加前後で計画が変わるかを素早く比較したいときです。
実行計画が毎回変わるのはなぜ?
主な理由は4つです。
- 統計情報が更新された。autovacuum/autoanalyzeが走ると選択率の推定が変わります
- パラメータの値が違う。
WHERE status = $1は値によって選択率が変わり、計画も変わります - PREPAREされた文のgeneric plan切り替え。同じprepared statementを繰り返し実行すると、PostgreSQLは6回目以降にカスタムプランの平均コストとジェネリックプランのコストを比較し、ジェネリックプランに切り替えることがあります。
plan_cache_modeでforce_custom_plan/force_generic_planに固定して比較できます - テーブルサイズの変化。行数が増えてSeq ScanからIndex Scanへ、あるいはその逆に切り替わります
計画が揺れて困る場合は、まず3番を疑ってください。アプリのORMがprepared statementを使っていると、負荷が上がったタイミングで突然遅くなるという形で現れます。
UPDATEやDELETEでEXPLAIN ANALYZEを試すには?
トランザクションで囲んでROLLBACKします。
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'done' WHERE id = 123;
ROLLBACK;
これなら実データは変わりませんが、注意点が2つあります。実行中は行ロックが取られ、範囲の広いUPDATEでは他セッションを待たせます。またWALは書かれ、ROLLBACKしたタプルもdead tupleとしてVACUUM対象に残ります。大量行で繰り返すとテーブル肥大化の要因になります。
トリガーがある場合は Trigger ...: time=... calls=... が出力されるので、遅さの原因がトリガーかどうかもここで分かります。
再発防止:遅いSQLを早期に検知する運用設計
EXPLAIN ANALYZEは「遅いと言われてから」使うツールです。言われる前に見つける仕組みを入れておきます。
まず、pg_stat_statementsで累積負荷の上位を定点観測します。
SELECT query, calls, total_exec_time, mean_exec_time, rows,
shared_blks_hit, shared_blks_read, temp_blks_written
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
見るべきは mean_exec_time(1回が遅い)だけでなく total_exec_time(合計で重い)です。1回5msでも100万回呼ばれていれば、それが最大の負荷源になります。temp_blks_written が大きい文は手順3のディスク溢れ候補なので、優先的にEXPLAIN ANALYZEにかけます。
次に、log_min_duration_statementで閾値超過を記録します。
log_min_duration_statement = 1000 # 1秒超を記録
log_lock_waits = on
log_temp_files = 0 # 一時ファイル生成をすべて記録
log_temp_files = 0 を入れておくと、work_mem不足によるディスク溢れがログに残ります。
auto_explainを使えば、遅いクエリの実行計画を自動取得できます。
shared_preload_libraries = 'pg_stat_statements,auto_explain'
auto_explain.log_min_duration = '3s'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_nested_statements = on
これで3秒超のクエリは実行計画付きでログに残り、「夜間だけ遅い」「再現しない」ケースを後から追えます。log_analyze = on は計測オーバーヘッドを伴うため、閾値を十分に高くし、本番導入前に負荷影響を確認してください。
最後にautovacuum/autoanalyzeの設定を見直します。推定行数のズレの大半は統計情報の鮮度に起因するので、更新が激しい大きなテーブルにはテーブル単位で閾値を下げた設定を入れます。
ALTER TABLE orders SET (
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_scale_factor = 0.05
);
既定の autovacuum_analyze_scale_factor = 0.1 は「全行の10%が変更されたらANALYZE」なので、1億行のテーブルでは1000万行変わるまで走りません。大規模テーブルでは明示的に下げておきます。
VACUUM・統計情報・ロック・待機イベントまで含めた切り分けの全体像は、PostgreSQL 運用・トラブル解決ガイドにまとめた流れも併せて確認してください。
実行計画は数字の意味を覚えるよりも、推定と実測のズレ、loopsの膨らみ、ディスク溢れ、I/O量という確認順序を身につけるほうが、実戦では速く原因にたどり着けます。出力を前にしたら、インデントの深い行から順にこの4点を当ててみてください。