- データベース
- 技術 💻
実行計画の見方|EXPLAIN ANALYZEの読み方を実行結果で解説
実行計画とは、データベースがSQLを「どの順番で、どの方法で」実行するかを示した手順書で、EXPLAIN ANALYZE を使うと、その手順に「実際にかかった時間と件数」を並べて確認できます。見るべきポイントは、どのノード(処理)で時間が急に増えているかと、見積もりの件数と実際の件数が大きくずれていないかの2つです。
この記事では、PostgreSQL 16.13で100万件の注文テーブルを作り、実際に EXPLAIN (ANALYZE, BUFFERS) を実行した出力を使って、実行計画を1行ずつ読み解きます。Seq ScanからIndex Scanへの変化、Nested LoopとHash Joinの違い、Sort Method: external merge の意味に加え、統計情報が古いせいで1.5秒かかっていたSQLが、ANALYZE 1回で0.5秒になった前後比較も紹介します。
実行計画の見方の早見表
| 表示 | 意味 | 見るポイント |
|---|---|---|
cost=0.42..10.49 |
見積もりのコスト(開始..完了)。相対値 | 時間ではない。計画同士の比較に使う |
rows=103(costの中) |
見積もりの件数 | 実際のrowsと桁が違えば統計情報を疑う |
width=32 |
1行の平均バイト数(見積もり) | 大きいと並べ替えのメモリが増える |
actual time=0.015..0.026 |
実際の時間(ms)。最初の1行..最後の行 | ループ1回あたりの平均 |
rows=101(actualの中) |
実際の件数(ループ1回あたりの平均) | loopsを掛けると合計件数 |
loops=3 |
そのノードが実行された回数 | 大きいほど内側が何度も繰り返されている |
Buffers: shared hit=9 read=3 |
メモリから読んだページ数/メモリになく読み込んだページ数 | 返す件数に比べて多すぎないか |
Rows Removed by Filter |
読んだあと条件で捨てた件数 | 多いと「読んでから捨てている」 |
Execution Time |
SQL全体の実行時間 | 改善前後の比較の基準 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「実行計画の見方|EXPLAIN ANALYZEの読み方を実行結果で解説」
EXPLAINとEXPLAIN ANALYZEの違い
EXPLAIN は計画だけを表示し、SQLは実行しません。EXPLAIN ANALYZE は実際にSQLを実行し、計画と実績を並べて表示します。BUFFERS を付けると、読んだページ数も表示されます。
実際に実行されるため、UPDATE・DELETEを調べるときはトランザクションで囲み、ROLLBACKで取り消します。
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'shipped' WHERE member_id = 4242;
ROLLBACK;
なお、公式リリースノートによると、PostgreSQL 18からは EXPLAIN ANALYZE だけで BUFFERS も自動で表示され、rowsも小数(例:rows=10.33)で表示されるようになりました。16・17では BUFFERS を明示します。
検証に使ったテーブル
-- 会員10万件
CREATE TABLE members (id int PRIMARY KEY, name text NOT NULL, rank text NOT NULL);
INSERT INTO members
SELECT g, 'member_' || g, (ARRAY['bronze','silver','gold'])[1 + (g % 3)]
FROM generate_series(1, 100000) g;
-- 注文100万件
CREATE TABLE orders (id bigint PRIMARY KEY, member_id int NOT NULL, status text NOT NULL,
amount int NOT NULL, created_at timestamptz NOT NULL);
INSERT INTO orders
SELECT g, 1 + (random() * 99999)::int,
CASE WHEN random() < 0.9 THEN 'shipped' WHEN random() < 0.5 THEN 'pending' ELSE 'canceled' END,
(random() * 10000)::int, timestamptz '2025-01-01' + g * interval '30 seconds'
FROM generate_series(1, 1000000) g;
VACUUM ANALYZE members; VACUUM ANALYZE orders;
時間は検証環境(データがメモリに載った状態)の値で、環境によって変わります。大事なのは絶対値ではなく、前後の比較と桁の違いです。出力はPlanning行など一部を省略しています。
読む順番:下(内側)から上へ
実行計画は木構造で、インデントが深いノードから先に実行され、結果が上のノードに渡されます。-> の付いた行が1つのノードです。後で出てくるHash Joinの計画は、次の順に処理されています。
flowchart BT
A["Seq Scan on members"] --> B["Hash(ハッシュ表を作る)"]
C["Parallel Seq Scan on orders"] --> D["Hash Join"]
B --> D
D --> E["HashAggregate(集計)"]
E --> F["最終結果"]
- 一番内側のスキャンから読む:どのテーブルを、どの方法で読んでいるか
- actual timeが急に増えるノードを探す:親の時間は子の時間を含むので、「子より急に増えたところ」が重い処理
- 見積もりrowsと実際のrowsを比べる:桁が違うノードは、計画の選び方がずれている可能性がある
スキャン方法の見方
Seq Scan:全件を読んで条件で捨てる
member_id にインデックスがない状態で、1人の会員の注文を探します。
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE member_id = 4242;
Gather (cost=1000.00..13630.43 rows=11 width=32) (actual time=0.274..37.433 rows=16 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=7421
-> Parallel Seq Scan on orders (cost=0.00..12629.33 rows=5 width=32) (actual time=4.678..30.922 rows=5 loops=3)
Filter: (member_id = 4242)
Rows Removed by Filter: 333328
Buffers: shared hit=7421
Execution Time: 37.476 ms
Parallel Seq Scanとloops=3:本体+ワーカー2つの3プロセスで分担して全件を読んでいます。rows=5は1プロセスあたりの平均ですRows Removed by Filter: 333328:1プロセスあたり約33万件を読んで捨てています。16件を返すために100万件を読んでいるのが重さの正体です
Bitmap Heap Scan:インデックスで場所を集めてから読む
CREATE INDEX orders_member_id_idx ON orders(member_id); を作って、同じSQLを実行します。
Bitmap Heap Scan on orders (cost=4.51..47.38 rows=11 width=32) (actual time=0.033..0.061 rows=16 loops=1)
Recheck Cond: (member_id = 4242)
Heap Blocks: exact=16
Buffers: shared hit=16 read=3
-> Bitmap Index Scan on orders_member_id_idx (cost=0.00..4.51 rows=11 width=0) (actual time=0.027..0.027 rows=16 loops=1)
Index Cond: (member_id = 4242)
Buffers: shared read=3
Execution Time: 0.080 ms
37.476ms → 0.080ms、読んだページは7,421 → 19になりました。Bitmap Index Scan で該当行のあるページの一覧を作り、Bitmap Heap Scan でそのページだけを読んでいます。
Index Scan と Index Only Scan
主キーの範囲(WHERE id BETWEEN 1000 AND 1100)で絞ると、Index Scan using orders_pkey になり、0.035msでした。Index Cond に条件が出ていれば、インデックスで絞り込めています。
必要な列がすべてインデックスにあれば、テーブル本体を読まずに済みます。(member_id, amount) の複合インデックスを作って確認しました。
SELECT member_id, amount FROM orders WHERE member_id BETWEEN 100 AND 110;
Index Only Scan using orders_member_amount_idx on orders (cost=0.42..6.71 rows=114 width=8) (actual time=0.013..0.021 rows=98 loops=1)
Index Cond: ((member_id >= 100) AND (member_id <= 110))
Heap Fetches: 0
Buffers: shared hit=3 read=1
Execution Time: 0.039 ms
Heap Fetches: 0 は、テーブル本体を1回も読まなかったという意味です。更新が多くVACUUMが追いついていないと、この数字が増えます。
| ノード | 動き | 選ばれやすい場面 |
|---|---|---|
| Seq Scan | 全件を先頭から読む | インデックスがない/該当行が多い/テーブルが小さい |
| Bitmap Index Scan+Heap Scan | ページを集めてページ順に読む | 該当行がほどほどに多く、散らばっている |
| Index Scan | インデックス順に1件ずつ本体を読む | 該当行が少ない、ORDER BY+LIMIT |
| Index Only Scan | インデックスだけで完結 | 必要な列がすべてインデックスにある |
Seq Scanは「悪いもの」ではありません。該当行がテーブルの大部分なら、全件を順に読むほうが速く、正しい選択です。
結合方法の見方:Nested LoopとHash Join
Nested Loop:外側の1行ごとに内側を探す
EXPLAIN (ANALYZE, BUFFERS)
SELECT m.name, o.id, o.amount
FROM members m JOIN orders o ON o.member_id = m.id
WHERE m.id BETWEEN 1 AND 3;
Nested Loop (cost=4.80..150.81 rows=30 width=24) (actual time=0.031..0.197 rows=29 loops=1)
-> Index Scan using members_pkey on members m (cost=0.29..8.35 rows=3 width=16) (actual time=0.008..0.009 rows=3 loops=1)
Index Cond: ((id >= 1) AND (id <= 3))
-> Bitmap Heap Scan on orders o (cost=4.51..47.38 rows=11 width=16) (actual time=0.012..0.059 rows=10 loops=3)
Recheck Cond: (m.id = member_id)
-> Bitmap Index Scan on orders_member_id_idx (cost=0.00..4.51 rows=11 width=0) (actual time=0.004..0.004 rows=10 loops=3)
Index Cond: (member_id = m.id)
Execution Time: 0.277 ms
Nested Loopは、上の子(外側)の1行ごとに、下の子(内側)を実行します。外側が3件なので内側は loops=3、rows=10 は1回あたりの平均で、合計は約30件(実際は29件)です。外側が少なく、内側をインデックスで引けるときに速い方法です。
Hash Join:片方でハッシュ表を作って突き合わせる
12月の注文を会員ランクごとに集計します(結合部分の抜粋)。
SELECT m.rank, count(*) FROM orders o JOIN members m ON m.id = o.member_id
WHERE o.created_at >= '2025-12-01' GROUP BY m.rank;
-> Hash Join (cost=2887.00..15558.98 rows=16248 width=6) (actual time=76.418..84.720 rows=12694 loops=3)
Hash Cond: (o.member_id = m.id)
-> Parallel Seq Scan on orders o (cost=0.00..12629.33 rows=16248 width=4) (actual time=34.865..37.146 rows=12694 loops=3)
Filter: (created_at >= '2025-12-01 00:00:00+00'::timestamp with time zone)
Rows Removed by Filter: 320640
-> Hash (cost=1637.00..1637.00 rows=100000 width=10) (actual time=40.657..40.658 rows=100000 loops=3)
Buckets: 131072 Batches: 1 Memory Usage: 5321kB
-> Seq Scan on members m (cost=0.00..1637.00 rows=100000 width=10) (actual time=0.028..11.560 rows=100000 loops=3)
Execution Time: 98.388 ms
Batches: 1 はハッシュ表がメモリ(work_mem)に収まったという意味で、2以上ならディスクに分けて処理しています。
| ノード | 向いている場面 | 注意する表示 |
|---|---|---|
| Nested Loop | 外側が少なく、内側をインデックスで引ける | 内側の loops が数万〜数十万 |
| Hash Join | 両方の件数が多い等価結合(=) | Batches が2以上 |
並べ替えの見方:Sort Method
並列処理を止め、work_mem(並べ替えに使えるメモリ。初期値4MB)のまま100万件を並べ替えます。
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders ORDER BY amount DESC, id;
Sort (cost=141004.84..143504.84 rows=1000000 width=32) (actual time=517.846..624.902 rows=1000000 loops=1)
Sort Key: amount DESC, id
Sort Method: external merge Disk: 41568kB
Buffers: shared hit=7427, temp read=10384 written=10403
-> Seq Scan on orders (cost=0.00..17421.00 rows=1000000 width=32) (actual time=0.004..76.325 rows=1000000 loops=1)
Execution Time: 680.072 ms
external merge Disk: 41568kB は、メモリに収まらず約41MBの一時ファイルを使って並べ替えたという意味です。ほかに quicksort Memory:(メモリ内で全件)、top-N heapsort(LIMITの上位N件だけを保持)があります。
SET work_mem = '256MB' にすると quicksort Memory: 79657kB に変わりましたが、各3回の実行時間は次のとおりで、この環境ではほとんど差がありませんでした。
| 設定 | Sort Method | Execution Time(3回) |
|---|---|---|
| work_mem 4MB | external merge | 520.8 / 491.3 / 490.8 ms |
| work_mem 256MB | quicksort | 550.5 / 525.1 / 559.9 ms |
一時ファイルがOSのキャッシュに載ると、externalでも大きく遅くならないことがあります。external merge を見たら、まず「全件を並べ替える必要があるか」を考えるのが近道です。
上位10件だけ必要なら、インデックスで並べ替えそのものをなくせます。LIMIT 10 を付けただけでは、100万件を読んで top-N heapsort するため178.113msかかりました。CREATE INDEX orders_amount_id_idx ON orders(amount DESC, id); を作ると次のようになりました。
Limit (cost=0.42..1.03 rows=10 width=32) (actual time=0.033..0.044 rows=10 loops=1)
-> Index Scan using orders_amount_id_idx on orders (cost=0.42..60096.42 rows=1000000 width=32) (actual time=0.032..0.042 rows=10 loops=1)
Execution Time: 0.058 ms
Sortノードが消え、178ms → 0.058msになりました。Index Scanの見積もりが rows=1000000 で実際が10件なのは、親のLimitが途中で止めたためで、問題のないずれです。
見積もりと実績のずれを読む:ANALYZE前後の比較
PostgreSQLは統計情報(列の値の分布)から件数を見積もります。統計情報が実データとずれていると、合わない計画が選ばれます。自動で統計を取る仕組み(autovacuum)をこのテーブルだけ止めて再現しました。
CREATE TABLE order_logs (id bigint, member_id int, status text) WITH (autovacuum_enabled = false);
CREATE INDEX order_logs_status_idx ON order_logs(status);
-- 最初の1,000件はすべて 'pending'。ここで統計を取る
INSERT INTO order_logs SELECT g, 1 + g % 1000, 'pending' FROM generate_series(1, 1000) g;
ANALYZE order_logs;
-- その後99.9万件を追加(ほとんど 'done')。統計は古いまま
INSERT INTO order_logs
SELECT g, 1 + g % 100000, CASE WHEN g % 100 = 0 THEN 'pending' ELSE 'done' END
FROM generate_series(1001, 1000000) g;
EXPLAIN (ANALYZE, BUFFERS)
SELECT m.rank, count(*) FROM order_logs l JOIN members m ON m.id = l.member_id
WHERE l.status = 'done' GROUP BY m.rank;
統計情報が古いとき:
GroupAggregate (cost=12.76..12.78 rows=1 width=14) (actual time=1457.232..1571.954 rows=3 loops=1)
Buffers: shared hit=2973260 read=910, temp read=1253 written=1263
-> Sort (cost=12.76..12.77 rows=1 width=6) (actual time=1400.402..1492.234 rows=989010 loops=1)
Sort Method: external merge Disk: 10024kB
-> Nested Loop (cost=0.72..12.75 rows=1 width=6) (actual time=0.042..1254.616 rows=989010 loops=1)
-> Index Scan using order_logs_status_idx on order_logs l (cost=0.42..4.44 rows=1 width=4) (actual time=0.021..154.094 rows=989010 loops=1)
Index Cond: (status = 'done'::text)
-> Index Scan using members_pkey on members m (cost=0.29..8.31 rows=1 width=10) (actual time=0.001..0.001 rows=1 loops=989010)
Index Cond: (id = l.member_id)
Execution Time: 1572.964 ms
見積もり rows=1 に対して、実際は rows=989010。統計上「'done' は1件もない」ため、「1件ならNested Loopで会員を1回引けば済む」と判断しました。実際には会員の検索が loops=989010(約99万回) 繰り返されています。
ANALYZE order_logs; を実行した後:
HashAggregate (cost=29291.12..29291.15 rows=3 width=14) (actual time=517.130..517.135 rows=3 loops=1)
Buffers: shared hit=7007
-> Hash Join (cost=2887.00..24350.79 rows=988067 width=6) (actual time=25.109..363.282 rows=989010 loops=1)
Hash Cond: (l.member_id = m.id)
-> Seq Scan on order_logs l (cost=0.00..18870.00 rows=988067 width=4) (actual time=0.066..121.911 rows=989010 loops=1)
Filter: (status = 'done'::text)
-> Hash (cost=1637.00..1637.00 rows=100000 width=10) (actual time=24.795..24.797 rows=100000 loops=1)
Execution Time: 517.189 ms
| 項目 | ANALYZE前 | ANALYZE後 |
|---|---|---|
| 見積もり rows / 実際の rows | 1 / 989,010 | 988,067 / 989,010 |
| 結合方法 | Nested Loop(内側 loops=989,010) | Hash Join |
| 読んだページ(shared hit) | 2,973,260 | 7,007 |
| Execution Time | 1,572.964 ms | 517.189 ms |
SQLもインデックスも変えずに、統計情報の更新だけで約3倍速くなりました。大量投入の直後は、autovacuumが統計を取る前にSQLが走ることがあるため、投入後に ANALYZE テーブル名; を実行するのが確実です。統計を取った時刻は pg_stat_user_tables の last_analyze・last_autoanalyze で確認できます。
「この表示が出たら疑う」チェックリスト
| 表示 | 疑うこと | まず試すこと |
|---|---|---|
| 見積もりrowsと実際のrowsが10倍以上違う | 統計情報が古い/列同士の相関 | ANALYZE、拡張統計(CREATE STATISTICS) |
内側ノードの loops が数万以上 |
外側を少なく見積もったNested Loop | 見積もりのずれを直す、結合キーのインデックス |
Rows Removed by Filter が返す件数よりはるかに多い |
読んでから捨てている | 条件列のインデックス、書き方の見直し |
Sort Method: external merge |
並べ替えがメモリに収まっていない | インデックス+LIMIT、work_memの調整 |
Hashの Batches が2以上 |
ハッシュ表がメモリに収まっていない | 結合前に件数を絞る、work_memの調整 |
Index Only Scanの Heap Fetches が多い |
VACUUMが追いついていない | VACUUM、autovacuum設定の確認 |
1回目の実行は遅く出やすいため、改善前後は2〜3回実行して安定した値で比べます。
MySQLのEXPLAIN ANALYZEとの違い
MySQLでは8.0.18から EXPLAIN ANALYZE が使えます。MySQL 8.4の公式リファレンスによると、MySQLでもSQLは実際に実行され、出力は常にTREE形式です。以下は公式リファレンスの出力例の抜粋です(この記事ではMySQLは実行していません)。
-> Inner hash join (t2.c2 = t1.c1) (cost=3.5 rows=5)
(actual time=0.121..0.131 rows=1 loops=1)
-> Table scan on t2 (cost=0.07 rows=5)
(actual time=0.0126..0.0221 rows=5 loops=1)
...
| 項目 | PostgreSQL | MySQL |
|---|---|---|
| actual time | ms、最初の行..最後の行、ループ平均 | ms、最初の行..最後の行、ループ平均 |
| 全件読み | Seq Scan |
Table scan on ... |
| インデックス | Index Scan・Index Only Scan |
Index lookup on ...・Covering index ... |
| 読んだページ数 | BUFFERS で表示 |
表示なし |
「内側から読む」「時間が急に増えるノードを探す」「見積もりと実績のずれを見る」という読み方は、MySQLでもそのまま使えます。
よくある質問
Q. costの数値は何ミリ秒ですか?
A. 時間ではありません。ページを順に1つ読むコストを1とした相対値です。実際の時間は actual time と Execution Time で確認します。
Q. actual rowsが0なのに結果が返ってきます。なぜですか?
A. rows はループ1回あたりの平均を丸めた値だからです。1,000回のループで合計300件なら平均0.3件で rows=0 と表示されます。loopsを掛けて合計を考えます(PostgreSQL 18からは小数で表示)。
Q. 同じSQLなのに、日によって実行計画が変わるのはなぜですか?
A. データ量や値の分布が変わって統計情報が更新されると、見積もりが変わり、別の計画が選ばれることがあります。遅くなったときの EXPLAIN (ANALYZE, BUFFERS) を取り、見積もりと実績のずれから確認します。
まとめ
- 実行計画は内側から上へ読み、actual timeが急に増えるノードを探す
actual timeとrowsはループ1回あたりの平均。loopsを掛けて合計を考える- 見積もりrowsと実際のrowsの桁のずれが、計画のずれのいちばんの手がかり
- 統計情報が古いと、1件と見積もったNested Loopが約99万回ループした。ANALYZEだけで1,573ms → 517ms
external mergeを見たら、インデックスとLIMITで並べ替えをなくせないかをまず考えるEXPLAIN ANALYZEは実際に実行される。更新系は BEGIN〜ROLLBACK で囲む
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「実行計画の見方|EXPLAIN ANALYZEの読み方を実行結果で解説」
株式会社RJCでは、SQLの速さを実行計画という根拠で確かめながら、仲間と一緒に改善を進めています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。