# PostgreSQL EXPLAIN ANALYZE の読み方とスロークエリ改善（実行計画の見方・ノード別の意味・auto_explain・v18対応）

> PostgreSQL の遅いクエリを EXPLAIN ANALYZE で診断し、確実に速くするための実務ガイド。cost/rows/width の読み方、見積もりと実測のズレ（統計の陳腐化）、Seq Scan・Bitmap・Nested Loop など各ノードの意味、外部ソートや Heap Fetches などの警告サイン、本番で遅いクエリの計画をログに残す auto_explain、PostgreSQL 18 の BUFFERS 既定ON までを公式ドキュメントに忠実に解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: PostgreSQL, パフォーマンス, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/postgresql-explain-analyze-slow-query-optimization-guide

## 要点

- EXPLAIN は見積もりだけ、EXPLAIN ANALYZE は実際に実行して実測を出す。DML（INSERT/UPDATE/DELETE）は BEGIN〜ROLLBACK で囲って安全に計測する
- 最重要は『見積もり rows と実測 rows のズレ』。桁違いに外れていたら統計が古い（ANALYZE で解決することが多い）
- actual の time/rows は loops あたりの平均。総コストは loops 倍。Nested Loop で loops が大きいと総時間が爆発する
- 警告サイン：大量の Rows Removed by Filter（索引不足）、Sort Method: external merge Disk（work_mem不足）、Heap Fetches が多い（VACUUM不足）
- PostgreSQL 18 では EXPLAIN ANALYZE が BUFFERS を既定表示。本番の遅いクエリは auto_explain で計画ごとログに残す（log_analyze の常時ONは高負荷なので注意）

---

「クエリが遅い」を直すのに、勘は要りません。PostgreSQL は **`EXPLAIN` で実行計画を、`EXPLAIN ANALYZE` で実測を**見せてくれます。読めるようになれば、「なぜ遅いか」が一目で分かり、打ち手（索引・統計更新・クエリ書き換え・メモリ）が確定します。

この記事は、[パフォーマンス総論](/blog/postgresql-performance-tuning-production-guide)の §2 で特定した「重いクエリ」を、実際に診断して直すための読み方ガイドです。公式ドキュメントの定義に忠実に、**現場で見る警告サインと対処**まで落とし込みます。

> **この記事のルール**：`EXPLAIN` の出力項目の意味・各オプション・PostgreSQL 18 の変更点は、すべて **PostgreSQL 18 公式ドキュメント（2026年6月時点）** に基づきます。コストは「ディスクページ取得を1.0とした任意単位」で、絶対値ではなく**プラン間の相対比較**に使うものです。

---

## 1. 4つの数字を読む：cost / rows / width

まず素の `EXPLAIN`（実行しない＝見積もりのみ）。

```sql
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
```

```text
Index Scan using idx_orders_cust on orders  (cost=0.42..8.45 rows=3 width=72)
  Index Cond: (customer_id = 42)
```

括弧内の4つの数字が基本です（公式 `using-explain`）。

| 項目 | 意味 |
| --- | --- |
| `cost=0.42..8.45` | **開始コスト..総コスト**。`0.42`＝最初の行を返せるまで、`8.45`＝全行返し終えるまで |
| `rows=3` | このノードが**出力すると見積もった行数** |
| `width=72` | 1行あたりの推定平均バイト数 |

ポイント。

- **開始コスト**は「出力開始までの準備」（ソートノードならソート完了まで）。
- **総コスト**は「最後まで読み切る前提」。親が `LIMIT` で途中で打ち切れば、実際はここまで掛かりません。
- **コストは累積**。親ノードのコストは子ノードを含む。
- 単位は任意（`seq_page_cost = 1.0` 基準）。**絶対値に意味はなく、プラン同士の比較**に使います。

> **PostgreSQL 18 の小ネタ**：`EXPLAIN` の `rows` が**小数表示**になり得ます（従来は整数に丸め）。微小な選択率がより正確に見えます。

---

## 2. EXPLAIN ANALYZE：実測を出す（DMLは要注意）

`ANALYZE` を付けると、PostgreSQL は**実際にクエリを実行**し、見積もりと**実測**を並べます。

```sql
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE customer_id = 42;
```

```text
Index Scan using idx_orders_cust on orders
  (cost=0.42..8.45 rows=3 width=72) (actual time=0.018..0.021 rows=3 loops=1)
  Index Cond: (customer_id = 42)
  Buffers: shared hit=4
Planning Time: 0.10 ms
Execution Time: 0.04 ms
```

`actual time=0.018..0.021 rows=3 loops=1` が実測です。**`(cost=…) (actual=…)` を左右で見比べる**のが診断の基本動作。

> **危険：ANALYZE は本当に実行する**。公式の警告どおり、`INSERT`/`UPDATE`/`DELETE`/`MERGE` に `EXPLAIN ANALYZE` を付けると**データが変わります**。`SELECT` の返却は破棄されますが、副作用は起きる。DML を計測するときは**必ずトランザクションで囲んでロールバック**します。

```sql
BEGIN;
EXPLAIN (ANALYZE) UPDATE orders SET status = 'shipped' WHERE id = 42;
ROLLBACK;   -- 計画は分かったが、変更は取り消す
```

---

## 3. 最重要：見積もり rows と実測 rows のズレ

公式が「最も重要」と明言するのは——**見積もり行数が実測にどれだけ近いか**。

```text
Seq Scan on orders
  (cost=0.00..18334.00 rows=1 width=72) (actual ... rows=48210 loops=1)
                       ^^^^^^                            ^^^^^^^^^^^
                       見積もり1行              実測は48,210行（桁違い！）
```

このように**桁違いにズレている**とき、プランナーは間違った前提で計画を立てています。原因はほぼ**統計情報の陳腐化**。`ANALYZE` で統計を取り直すと、多くの場合この種の問題は解決します。

```sql
ANALYZE orders;   -- 統計を更新してから、もう一度 EXPLAIN ANALYZE
```

それでもズレるなら、**相関する複数列**（プランナーは独立と仮定する）が原因のことが多い。`CREATE STATISTICS` で拡張統計を与えます（[総論 §3](/blog/postgresql-performance-tuning-production-guide) 参照）。見積もりが正しくなれば、プランナーは自然と良い計画を選びます。**「索引を強制する」より「統計を正す」が王道**です。

---

## 4. loops の罠：実測値は「1ループあたりの平均」

ここを誤読する人が非常に多い。公式の定義：

> サブプランノードが複数回実行される場合、`loops` は**総実行回数**を表し、`actual time` と `rows` は**1回あたりの平均**で表示される。**総時間を知るには loops を掛ける**。

つまり `actual time=0.5 rows=2 loops=10000` は「平均0.5ms × 1万回 ＝ 合計5秒」。Nested Loop の内側で起きると、見た目の小さな数字が**総時間では爆発**します。

```text
Nested Loop  (actual time=... rows=10000 loops=1)
  ->  Seq Scan on a   (actual ... rows=10000 loops=1)
  ->  Index Scan on b (actual time=0.05..0.06 rows=1 loops=10000)
                                                        ^^^^^^^^^^
                          内側が1万回実行 → 0.06ms × 10000 ≈ 0.6秒がここに隠れている
```

外側の行数が多いのに Nested Loop が選ばれているなら、**行数の見積もり誤り**でプランナーが Hash/Merge 結合を選び損ねている可能性大。やはり統計を疑います。

---

## 5. ノードの意味：何が選ばれたら何を意味するか

実行計画は木構造で、葉がスキャン、枝が結合・集約です。主要ノードの意味と「出たら何を考えるか」。

### スキャン系

| ノード | 意味 | 出たときの考え |
| --- | --- | --- |
| **Seq Scan** | 全行を順に読む | 大量行を返すなら正常。少数を返すのに出たら**索引不足**を疑う |
| **Index Scan** | 索引で位置特定→ヒープ取得 | 選択的な条件・整列に最適。理想形の一つ |
| **Index Only Scan** | 索引だけで完結（ヒープ不要） | 最速級。`Heap Fetches` を確認（[索引の記事](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide)） |
| **Bitmap Index Scan → Bitmap Heap Scan** | 位置をビットマップに集めてから物理順でヒープ取得 | 中程度の選択率。Seq と Index の中間で効率的 |

### 結合系

| ノード | 意味 | 得意な状況 |
| --- | --- | --- |
| **Nested Loop** | 外側1行ごとに内側を再スキャン | 外側が**少数**のとき。多いと爆発（§4） |
| **Hash Join** | 片方からハッシュ表を作り突き合わせ | 大きい非整列の等値結合 |
| **Merge Join** | 両側を整列してマージ | 両側が**既に整列済み**の大規模結合 |

```sql
-- VERBOSE で出力列・スキーマ修飾名まで、FORMAT json で機械可読に出せる
EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON) SELECT ...;
```

---

## 6. 警告サイン集：これが出たらこう直す

実行計画から「遅い理由」を読み取る、現場のパターン集です。

### ① `Rows Removed by Filter` が巨大 → 索引不足

```text
Seq Scan on orders  (actual ... rows=120 loops=1)
  Filter: (status = 'pending')
  Rows Removed by Filter: 1488000   ← 149万行を読んで120行に絞った（無駄が大きい）
```

149万行を読んでフィルタで捨てている。`status` に索引（または部分索引）を張れば、読む行を最初から絞れます。

### ② `Sort Method: external merge  Disk: …` → work_mem 不足

```text
Sort  (actual time=820..950 rows=500000 loops=1)
  Sort Key: created_at
  Sort Method: external merge  Disk: 86016kB   ← メモリに収まらずディスクへ退避
```

ソートが `work_mem` を溢れてディスクに書いた証拠。**そのクエリだけ** `SET LOCAL work_mem` を上げるか（[総論 §4.2](/blog/postgresql-performance-tuning-production-guide)）、`ORDER BY` を満たす索引を用意してソート自体を消します。`Memory: NkB`（quicksort）ならメモリ内で完結＝健全。

### ③ `Heap Fetches` が多い → VACUUM 不足

```text
Index Only Scan using idx_orders_cust_covering on orders
  (actual ...) Heap Fetches: 43120   ← index-only のはずがヒープを4万回も見ている
```

可視性マップが all-visible でないため、index-only scan がヒープを見に行っている。対象表を VACUUM すれば `Heap Fetches` は下がります（[MVCC/VACUUM の記事](/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide)）。

### ④ Nested Loop で内側の `loops` が巨大 → 結合方式の誤選択

§4 のとおり。多くは見積もり誤り。`ANALYZE`／拡張統計で見積もりを正すのが本筋です。

---

## 7. BUFFERS でキャッシュ効率を読む（PG18で既定ON）

`BUFFERS` は、各ノードが触れたバッファ数を出します。**メモリに乗っているか、ディスクを叩いているか**が分かります。

| 項目 | 意味 |
| --- | --- |
| `shared hit` | キャッシュ命中（ディスク読み回避） |
| `shared read` | ディスクから読んだ（コールド） |
| `shared dirtied` | このクエリが変更した未変更ブロック |
| `shared written` | このクエリ実行中に追い出された dirty ブロック |

> **PostgreSQL 18 の変更**：`EXPLAIN ANALYZE` は **`BUFFERS` を自動で含む**ようになりました（公式：「Buffers information is automatically included when ANALYZE is used.」）。PG17 以前は明示が必要でした。抑制したいときだけ `EXPLAIN (ANALYZE, BUFFERS OFF)`。

```text
Index Scan ... Buffers: shared hit=4          ← 全部キャッシュ命中（理想）
Seq Scan   ... Buffers: shared read=18334     ← 1.8万ブロックをディスクから読んだ（重い）
```

同じクエリを2回実行して `read` が `hit` に変われば、2回目はキャッシュに乗った証拠。**初回（コールド）と2回目（ウォーム）を区別**して評価しましょう。PG18 ではこのほか、`EXPLAIN ANALYZE` が**インデックススキャンごとの索引探索回数**を報告し、`Material`/`Window Aggregate`/CTE ノードのメモリ・ディスク使用量も出るようになりました。

---

## 8. 本番のスロークエリを捕まえる：auto_explain

開発中は `EXPLAIN ANALYZE` を手で叩けますが、本番では「いつ・どのクエリが遅かったか」を後から追う必要があります。そのための拡張が **`auto_explain`**——**しきい値を超えたクエリの実行計画を自動でログに残します**。

```ini
# postgresql.conf（shared_preload_libraries 推奨）
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '500ms'   # 500ms 超のクエリの計画をログ（-1=無効, 0=全件）
auto_explain.log_format = 'json'          # 構造化ログとして集約しやすい
auto_explain.sample_rate = 1.0            # 高トラフィックでは下げて負荷を抑える
```

**最重要の注意**（公式が強く警告）：`auto_explain.log_analyze = on` にすると **`EXPLAIN ANALYZE` 相当**になり計画だけでなく実測も取れますが、「**ログ対象でない短いクエリも含め全クエリでノード単位の計時が走り、性能に極めて悪影響を与え得る**」。本番で常時ONは避け、必要なときだけ・`log_timing = off` や `sample_rate < 1` と併用して負荷を抑えます。

| パラメータ | 既定 | 役割 |
| --- | --- | --- |
| `log_min_duration` | `-1`（無効） | この時間(ms)を超えたら計画を記録。`0` で全件 |
| `log_analyze` | off | 実測も取る。**高負荷注意** |
| `log_timing` | on | ノード単位の計時（`log_analyze` 前提）。offで負荷軽減 |
| `log_buffers` | off | BUFFERS を含める（`log_analyze` 前提） |
| `sample_rate` | 1 | 記録するクエリの割合。高負荷時に下げる |

---

## 9. 診断〜改善のワークフロー（まとめ）

1. **特定**：`pg_stat_statements` で累計時間の長いクエリを選ぶ（[総論 §2](/blog/postgresql-performance-tuning-production-guide)）。
2. **計測**：`EXPLAIN (ANALYZE)` を実行（DML は `BEGIN; … ROLLBACK;`）。
3. **見積もりズレを確認**：`rows` 見積もり vs 実測。桁違いなら **`ANALYZE` → 拡張統計**。
4. **重いノードを特定**：`actual time`（× `loops`）が大きいノード、`Rows Removed by Filter`、`external merge Disk`、`Heap Fetches`。
5. **打ち手を確定**：
   - 大量フィルタ → **索引**（[索引の記事](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide)）
   - 外部ソート → **`work_mem` or 整列索引**
   - Heap Fetches → **VACUUM**
   - 結合誤選択 → **統計の修正**
6. **再計測**：同じ `EXPLAIN ANALYZE` で改善を数値で確認（コールド/ウォームに注意）。
7. **本番監視**：`auto_explain` で再発を検知。

> **やってはいけない**：`SET enable_seqscan = off` 等でプランナーを力技で曲げるのは、原因を隠す対症療法。デバッグ時の切り分けには使えますが、本番常用は禁物です。**正しい統計と索引で、プランナーに正しい選択をさせる**のが本筋。

---

## 10. まとめ

- `EXPLAIN` は見積もり、`EXPLAIN ANALYZE` は実測。**両者を左右で見比べる**。
- **見積もりと実測のズレが最重要シグナル**。桁違いなら統計の問題（`ANALYZE`／拡張統計）。
- `actual` は **loops あたりの平均**。総時間は loops 倍——Nested Loop の罠に注意。
- 警告サイン（`Rows Removed by Filter` / `external merge Disk` / `Heap Fetches`）ごとに打ち手は決まっている。
- **PG18 は `EXPLAIN ANALYZE` が BUFFERS 既定ON**。本番は `auto_explain` で計画を記録（`log_analyze` 常時ONは高負荷）。

実行計画が読めれば、チューニングは「勘」から「診断」になります。次は、計画を左右する物理レイヤー——[MVCC とトランザクション、VACUUM](/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide) へ。

---

### 参考（PostgreSQL 18 公式ドキュメント）

- [14.1. Using EXPLAIN](https://www.postgresql.org/docs/18/using-explain.html)
- [EXPLAIN（SQLコマンド・全オプション）](https://www.postgresql.org/docs/18/sql-explain.html)
- [14.2. Statistics Used by the Planner](https://www.postgresql.org/docs/18/planner-stats.html)
- [F.4. auto_explain](https://www.postgresql.org/docs/18/auto-explain.html)
