実行計画の読み方

この実行計画の、どこが遅いか指せますか。

著者 たいてっく(エンジニア講師 / データエンジニア)最終更新

まず、これがそのクエリです。読めると思います。

SELECT c.name, count(*), sum(i.price * i.qty) AS total
FROM customers c
JOIN orders o      ON o.customer_code = c.code
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'shipped'
  AND o.ordered_at >= '2026-07-01'
  AND o.channel = 'web'
  AND o.payment = 'card'
  AND i.qty = 3
GROUP BY c.name
ORDER BY total DESC
LIMIT 10;

やっていることは「7 月以降の web / カード決済で発送済みの注文から、数量 3 の明細を集めて、顧客ごとの売上上位 10 件」。SQL としては素直な部類。

そして下が、このクエリの実行計画です。実際に2.16 秒かかっています。遅い理由はこの中に全部書いてあります。ただし読み方を知らないと、1 行も取り出せません。

 Limit  (cost=196523.68..196523.70 rows=10 width=30) (actual time=2160.299..2160.379 rows=10.00 loops=1)
   ->  Sort  (cost=196523.68..196526.29 rows=1046 width=30) (actual time=2160.293..2160.320 rows=10.00 loops=1)
         Sort Key: (sum((i.price * i.qty))) DESC
         Sort Method: top-N heapsort  Memory: 26kB
         ->  GroupAggregate  (cost=196477.54..196501.07 rows=1046 width=30) (actual time=2090.929..2158.921 rows=20000.00 loops=1)
               Group Key: c.name
               ->  Sort  (cost=196477.54..196480.15 rows=1046 width=22) (actual time=2090.883..2131.767 rows=500000.00 loops=1)
                     Sort Key: c.name
                     Sort Method: external merge  Disk: 16656kB
                     ->  Nested Loop  (cost=597.43..196425.08 rows=1046 width=22) (actual time=3.118..1561.364 rows=500000.00 loops=1)
                           ->  Hash Join  (cost=597.00..57141.20 rows=523 width=18) (actual time=3.087..177.650 rows=250000.00 loops=1)
                                 Hash Cond: (o.customer_code = c.code)
                                 ->  Seq Scan on orders o  (cost=0.00..56537.00 rows=526 width=11) (actual time=0.129..127.002 rows=250000.00 loops=1)
                                       Filter: ((ordered_at >= '2026-07-01'::date) AND (status = 'shipped'::text) AND (channel = 'web'::text) AND (payment = 'card'::text))
                                       Rows Removed by Filter: 1750000
                                 ->  Hash  (cost=347.00..347.00 rows=20000 width=21) (actual time=2.844..2.847 rows=20000.00 loops=1)
                                       Buckets: 32768  Batches: 1  Memory Usage: 1309kB
                                       ->  Seq Scan on customers c  (cost=0.00..347.00 rows=20000 width=21) (actual time=0.053..1.144 rows=20000.00 loops=1)
                           ->  Index Scan using order_items_order_id_idx on order_items i  (cost=0.43..266.09 rows=23 width=12) (actual time=0.005..0.005 rows=2.00 loops=250000)
                                 Index Cond: (order_id = o.id)
                                 Filter: (qty = 3)
                                 Rows Removed by Filter: 4
                                 Index Searches: 250000
 Planning Time: 3.419 ms
 Execution Time: 2162.557 ms

採取環境と再現用 SQL はこのページの末尾にあります。
PostgreSQL 18.6 / 2026-08-22 採取 / 並列・JIT は OFF

このセクションを読み終えると、この計画が読めます。さらに、2.16 秒のうち 1.25 秒を持っているノードがどれかを、 自分で指せるようになります。

先に答えだけ言っておくと、真犯人は上から 19 行目にいます。 そして素朴に読むと、そのノードは全ノード中いちばん軽く見えます(actual time=0.005..0.005 と書いてあるので)。 なぜそう見えてしまうのかが、このセクションの中心にある話です。

読み方を知らないと詰まるのは、記事が想定しているより手前

実行計画の解説はたくさんありますが、多くは「Seq Scan は遅い」から始まります。実際に読者が詰まるのはその手前です。

  • 形式そのものが未知。インデントが木構造だと知らなければ、 どこから読むのかすら分かりません(「上から順に実行される」が最頻出の誤読です)
  • 数字の単位が独特。cost は秒ではありません。actual time は 2 つの数字で、 しかも loops があるときは 1 回あたりの平均です
  • そもそも打ち方が分からない。psql を触ったことがない場合、GUI クライアントや ORM からどう出すのかで止まります

このセクションはそこから始めます。

実行計画とは

実行計画とは、SQL が「何が欲しいか」しか書いていないのに対して、データベースが「どうやって取るか」を決めた手順書であり、木構造で表現される。同じ結果を返す取り方が複数あるとき、オプティマイザが統計情報をもとにコストを見積もって 1 つを選ぶ。

読めるようになるまでの地図

STEP 1

読めるようになる

記号の意味も読む順番も分からない状態から、木を内側から読めるところまで。

STEP 2

原因を指せるようになる

読めるようになった人が、どのノードが遅いのかを手順で特定できるところまで。

STEP 3

数字の出どころが分かる

犯人が分かった人が、なぜ DB がその数字を出したのかまで遡る。

遅いクエリを渡されたときの手順

結論だけ先に置いておきます。詳しい実演は遅いノードの見つけ方にあります。

  1. EXPLAIN (ANALYZE) を打つ(SELECT 以外で打つと本当に更新されるので注意)
  2. 各ノードの actual time の上端に loops を掛けて、 そこから子の合計を引く。これがそのノードの自分の時間
  3. 全ノードを自分の時間で降順に並べる
  4. 見積り rows の 10 倍以上の行が実際に返っているノードを探す。 そこが根本原因の候補
  5. Rows Removed by Filter が大きければ、その分は読む必要のなかった行

この手順は絶対値ではなく比率で読むようにできています。 同じクエリでも実行時間はキャッシュの状態で何倍も動くので、 「何 ms かかったか」より「どのノードが何割を持っているか」のほうが安定するためです。

付録: 手元で同じ計画を出す

このセクションが出している計画はすべて実出力です。 題材のデータを作る SQL を全文載せておくので、同じものを手元で再現できます。Docker があれば 2 分で始められます。

docker run -d --name qp -e POSTGRES_PASSWORD=qp -p 55432:5432 postgres:18
psql -h localhost -p 55432 -U postgres
題材のデータを作る SQL(全文・約 1 分 20 秒 / 3.5GB)
-- 実行計画の題材を手元に作る。PostgreSQL 18 で確認。
-- 所要時間: 約 1 分 20 秒 / ディスク使用量: 約 3.5 GB
--
-- このデータが「遅いクエリ」になるように仕込んである点は 4 つ。
--   1. orders の 4 条件を完全に相関させる
--      → プランナは選択率を独立と仮定して掛けるので、25 万行を 500 行前後と見積もる
--      → その結果 Hash Join ではなく Nested Loop が選ばれ、内側が 25 万回まわる
--   2. order_items を「商品順」に物理配置する
--      → 同じ注文の明細がページ上に散らばる
--      → 1 回あたりのヒープ取得が効かなくなり、per-loop が 1 桁上がる
--   3. 1 注文あたり明細 6 行 + クエリ側で qty = 3 に絞る
--      → 内側は 6 行ぶん働くのに、上に流れるのは 2 行
--   4. 明細行に product_name / note を持たせて現実的な幅にする
--      → テーブルが 3.5GB になり、メモリに載り切らない
--
-- 1 と 2 が無いと Execution Time が 100ms 台になり、教材として成立しない。

DROP TABLE IF EXISTS order_items, orders, customers;

CREATE TABLE customers (
    id   int PRIMARY KEY,
    code text NOT NULL,          -- 業務キー。あえてインデックスを張らない
    name text NOT NULL
);
INSERT INTO customers
SELECT g, 'C-' || g, 'customer-' || g FROM generate_series(1, 20000) g;

CREATE TABLE orders (
    id            int PRIMARY KEY,
    customer_code text NOT NULL,
    status        text NOT NULL,
    channel       text NOT NULL,
    payment       text NOT NULL,
    ordered_at    date NOT NULL
);

-- id <= 250000 が「4 条件すべてを満たす行」。4 条件は完全に相関している
INSERT INTO orders
SELECT g,
       'C-' || (1 + (g % 20000)),
       CASE WHEN g <= 250000 THEN 'shipped' ELSE (ARRAY['pending','cancelled','returned'])[1 + (g % 3)] END,
       CASE WHEN g <= 250000 THEN 'web'     ELSE (ARRAY['store','phone','partner'])[1 + (g % 3)]        END,
       CASE WHEN g <= 250000 THEN 'card'    ELSE 'cash'                                                 END,
       -- 条件を満たさない行は必ず 2026-06-30 以前に収める
       CASE WHEN g <= 250000 THEN DATE '2026-07-01' + (g % 31) ELSE DATE '2025-08-01' + (g % 334) END
FROM generate_series(1, 2000000) g;

CREATE TABLE order_items (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id     int  NOT NULL,
    product_name text NOT NULL,
    note         text NOT NULL,
    price        int  NOT NULL,
    qty          int  NOT NULL
);
-- ORDER BY で物理順を「商品順」にする(移行やバルクロードでよくある形)
INSERT INTO order_items (order_id, product_name, note, price, qty)
SELECT o.id, 'product-' || (o.id % 5000), repeat('note ', 40), 100 + (o.id % 900), 1 + (s % 3)
FROM orders o, generate_series(1, 6) s
ORDER BY (o.id % 5000), o.id;

CREATE INDEX order_items_order_id_idx ON order_items (order_id);

ANALYZE customers;
ANALYZE orders;
ANALYZE order_items;

-- ここから計画を採る。この 2 つの SET を先に打つこと(打たないと形が変わる)
SET max_parallel_workers_per_gather = 0;
SET jit = off;

EXPLAIN (ANALYZE)
SELECT c.name, count(*), sum(i.price * i.qty) AS total
FROM customers c
JOIN orders o      ON o.customer_code = c.code
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'shipped'
  AND o.ordered_at >= '2026-07-01'
  AND o.channel = 'web'
  AND o.payment = 'card'
  AND i.qty = 3
GROUP BY c.name
ORDER BY total DESC
LIMIT 10;

-- Execution Time はコールド単発でも 1.8〜4.5 秒に振れる。1 回だけ見て判断しないこと。
-- 安定しているのは「構造」と、内側の Index Scan が 1 位で 2 位を大きく引き離すこと。
採取環境

PostgreSQL 18.6(postgres:18 公式イメージ / 設定は既定値)。2026-08-22 採取。計画ごとにコンテナを再起動して 5 回まわし、中央値の run を載せています。

次の 2 つだけ既定から変えています。

SET max_parallel_workers_per_gather = 0;
SET jit = off;
  • 並列を切っている理由 — 既定のままだと Gather / Parallel Seq Scan が入り、loops=2 がワーカー間の平均という別の意味で現れます。このセクションが教える 「loops は繰り返し回数」と衝突するので切っています
  • JIT を切っている理由 — 計画の末尾に JIT のブロックが増えるだけで、読み方の話には要らないためです

手元で同じ計画を出すときも、この 2 行を先に打ってください。打たないと形が変わります。実行時間の絶対値はマシンとキャッシュの状態で 何倍も動くので、比率で読むのが前提です。

よくある疑問

Q.実行計画はどこから読むのですか?
A.上からではなく、インデントがいちばん深い行から読みます。矢印(->)が付いた行が子ノードで、親は子の結果を受け取ってから動きます。いちばん上の行が最後に実行されます。
Q.cost の単位は秒ですか?
A.秒ではありません。ページを 1 枚順に読む手間を 1.0 とした相対値です。実行時間と桁を比べても意味がないので、cost どうし・時間どうしで比べます。
Q.actual time が小さいのに、そのノードが遅いことはありますか?
A.あります。loops が 1 より大きいノードでは 1 回あたりの平均が表示されるので、総量に戻すには loops を掛けます。表示が 0.005ms でも 25 万回まわっていれば 1.25 秒です。

もっと学びたい方へ(おすすめ書籍)

SQL実践入門 ── 高速でわかりやすいクエリの書き方
ミック

「なぜこの書き方が速いのか」を実行計画から説明する一冊。条件分岐・集約・結合・更新のそれぞれで、良い書き方と悪い書き方を対比しながら読める。

Amazon で見る →
おすすめ
達人に学ぶDB設計徹底指南書 第2版
ミック

テーブル設計と正規化、パフォーマンス考慮のインデックス設計まで実務レベルで学べる定番書。第2版ではクラウド対応も強化。

Amazon で見る →
おすすめ
楽々ERDレッスン (CodeZine BOOKS)
羽生章洋

ER 図をどう「使える設計」に落とすか、実務の判断まで踏み込んだ入門書。エンティティの切り出しから多対多の扱いまで具体例が豊富。

Amazon で見る →
おすすめ
スッキリわかるSQL入門 第4版 ドリル256問付き!
中山清喬/飯田理恵子

ドリル 256 問を実際に打ちながら進める SQL の入門書。付属のブラウザ環境で演習できるので、SELECT から結合・集約までを環境構築で止まらずに通せる。

Amazon で見る →
達人に学ぶSQL徹底指南書 第2版
ミック

SQLの本質的な使い方と、インデックスが効くクエリの書き方を学べる。ウィンドウ関数など現代SQLも網羅。

Amazon で見る →
SQLアンチパターン 第2版
Bill Karwin

実務でやりがちなSQL・DB設計のアンチパターンとその回避策を体系的に学べる。

Amazon で見る →
[改訂3版]内部構造から学ぶPostgreSQL
勝俣智成 ほか

PostgreSQLの内部構造・ストレージ・インデックス機構を丁寧に解説。設計と運用計画の鉄則が学べる。

Amazon で見る →
情報処理教科書 データベーススペシャリスト 2025年版
三好康之

IPAデータベーススペシャリスト試験の総合対策書。インデックス関連は本サイトと合わせて学ぶと理解が深まる。

Amazon で見る →
理論から学ぶデータベース実践入門
奥野幹也

リレーショナルモデルの理論から、インデックス設計を含む実務で使えるSQLまで解説。

Amazon で見る →

本セクションはAmazonアソシエイトのリンクを含みます。

オンライン個別指導

もっと深くDBを学びたい方へ。

たいてっくが、SQL・データベース設計・パフォーマンスチューニング・IPAデータベーススペシャリスト対策まで、1対1で学習をサポートします。まずは無料相談から。

無料相談を予約する →
受講者の声 (DB・SQL コース)
menta レビュー原文を見る →
  • 「教え方も上手で、お人柄も良いメンターです。DB周りの知識はもちろん、何より、しっかり教えてあげようという姿勢がとてもありがたかったです。データベース、SQLの学習を考えている方にはおススメです。」
    — H 様(DB・SQL コース受講)
  • 「体系的に知識を教えてくださり、実際の業務でも大変役立っております。特に短い時間で効率よく知識の習得や、練習をできているのは期待以上でした。」
    — M 様(DB・SQL コース受講)
  • 「大変充実したコンテンツでわかりやすいご説明をありがとうございました。基本的な質問にも丁寧にご説明いただき、また業務のご相談にも乗って頂き大変有意義な時間でした。」
    — K 様(DB・SQL コース受講)