実行計画とは、データベースが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["最終結果"]
  1. 一番内側のスキャンから読む:どのテーブルを、どの方法で読んでいるか
  2. actual timeが急に増えるノードを探す:親の時間は子の時間を含むので、「子より急に増えたところ」が重い処理
  3. 見積もり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期連続の黒字決算。

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

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