Claude CodeでEXPLAIN ANALYZEの実行計画を読む — 遅いクエリの絞り込み手順
EXPLAIN ANALYZEはクエリを実際に実行します。BEGINとROLLBACKで包む取得スクリプト、Claude Codeの権限設定、推定行数のずれの読み方、索引追加後の取り直しまでを手順にしました。
遅いクエリの原因を絞るには、PostgreSQLの EXPLAIN (ANALYZE, BUFFERS) の出力をClaude Codeに読ませるのが速い方法です。ただし、この文を実行すると、クエリは実際に動きます。更新系の文なら、テーブルの中身も実際に変わります。
先に決めておくのは、取得の安全装置です。計画の読解はその後でも間に合います。この記事では、取得を決まった入口に閉じ込める構成から始めます。続けて、出力の中で見る場所、推定行数のずれの扱い、索引を足したあとの取り直しを順に説明します。
EXPLAIN ANALYZEの前に決める3つのこと
EXPLAIN だけなら、プランナーが立てた計画を表示するだけで、クエリは動きません。ANALYZE を付けると文が実際に実行され、各ノードの実測時間と実際の行数が加わります。PostgreSQLのマニュアルは、SELECTの結果は捨てられるが、それ以外の副作用は通常どおり起きると明記しています。
Claude Codeに任せる前に、次の3点を決めます。
- どこで動かすか: 本番ではなく、検証用の環境かレプリカを使う構成が候補になります。実行する以上、負荷がかかるためです
- 更新系をどう扱うか: INSERT・UPDATE・DELETE・MERGEは
BEGINとROLLBACKで包みます。マニュアルが示している方法です - 誰がDBに触るか: Claude Codeに接続情報を渡して
psqlを自由に打たせるのか、決まった入口だけを許すのかを選びます
三つ目は、Claude Codeの権限設計の話です。次の節で入口を一本にします。
取得を一本のスクリプトに閉じ込める
SQLファイルを渡すと、BEGIN と ROLLBACK で包んで EXPLAIN (ANALYZE, BUFFERS, SETTINGS) を実行し、結果を plans/ に保存するスクリプトを用意します。Claude Codeには、このスクリプトと保存済みの結果だけを触らせます。
#!/usr/bin/env bash
# scripts/explain.sh — SQLファイルを1つ受け取り、実行計画を plans/ に保存する
set -euo pipefail
sql_file="$1"
out="plans/$(basename "${sql_file%.sql}").txt"
# ファイル側のCOMMITでトランザクションが確定するのを防ぐ
if grep -qi 'commit' "$sql_file"; then
echo "COMMITを含むSQLは対象外です" >&2
exit 1
fi
mkdir -p plans
{
echo 'BEGIN;'
echo 'EXPLAIN (ANALYZE, BUFFERS, SETTINGS)'
cat "$sql_file"
echo ';'
echo 'ROLLBACK;'
} | psql -X -v ON_ERROR_STOP=1 "$DATABASE_URL" > "$out"このスクリプトは例です。$DATABASE_URL には、検証用DBかレプリカの接続文字列を入れます。更新系の文は ROLLBACK で元に戻りますが、戻るのはテーブルの変更です。関数が外部のサービスを呼ぶような処理は、トランザクションでは取り消せません。そうした関数を含む文は、スクリプトに通さない運用が無難です。
SETTINGS を付けるのは、プランナーの設定が既定値と違う場合に、その差が出力へ載るからです。Claude Codeに原因を絞らせるとき、設定の差は手がかりになります。
権限の設定: 入口以外を閉じる
Claude Codeの権限ルールは、deny、ask、allow の順に評価され、最初に当たったものが結果になります。広い deny に対して、狭い allow で例外を作ることはできません。この順序を前提に、プロジェクトの .claude/settings.json を組みます。
{
"permissions": {
"allow": [
"Bash(./scripts/explain.sh *)",
"Read(./queries/**)",
"Read(./plans/**)"
],
"deny": [
"Bash(psql *)",
"Read(./.env)",
"Edit(/scripts/explain.sh)"
]
}
}Bash(psql *) を deny に置くと、Claudeが書いた psql ... は止まります。一方で、Bashルールは書かれたコマンドの文字列に対して当たります。/usr/bin/psql のようにパスを付けた形や、sh -c の中の psql は、同じルールでは止まりません。ルールは歯止めの一枚で、境界ではありません。
境界が必要なら、二つの手があります。ひとつはDB側の権限です。接続文字列のロールを、検証用DBの必要な範囲に絞ります。もうひとつはサンドボックスで、ファイルとネットワークをコマンドの文字列に頼らずに制限できます。
Edit(/scripts/explain.sh) の deny は、取得スクリプト自体を書き換えさせないためです。/ で始まるパスは、設定ファイルのあるプロジェクトの基準で解釈されます。Bash(./scripts/explain.sh *) の * には任意の引数が入るので、引数の中身は検査されません。SQLファイルの中身を縛る役は、スクリプト側の commit 検査とDBロールが持ちます。
権限の書き方をほかの言語や道具で見たい場合は、OpenTofuでvalidateとplanだけ許可する権限設計が同じ型で組んでいます。
CLAUDE.mdに書いておく運用ルール
入口を閉じても、Claudeが別の手を試す余地は残ります。方針は CLAUDE.md にも書いておくと、最初の動きが揃います。
## 遅いクエリの調査
- 実行計画は `scripts/explain.sh queries/<名前>.sql` で取得し、`plans/<名前>.txt` を読む
- psql を直接実行しない。DDL(CREATE INDEX など)は提案だけして、実行しない
- 計画を読むときは、推定行数と実際の行数、loops、Buffers を必ず表にして示す
- 原因の仮説は、確かめる手順とセットで出すCLAUDE.md は動作の傾向を作るもので、強制力はありません。強制は前の節の deny が担います。二つを併用する構成です。
出力を渡す: クエリ、計画、索引の三点セット
Claude Codeに渡す材料は三つです。
- クエリ本体(
queries/のSQLファイル) - 実行計画(
plans/のテキスト) - 関係するテーブルの定義と索引の一覧(
psqlの\d テーブル名の出力など)
計画だけ渡しても、索引が存在しないのか、あるのに使われていないのかは区別できません。三点がそろうと、「索引が無い」「あるが使えない形でクエリが書かれている」「あるが統計が古い」の切り分けに進めます。
取得から仮説までの流れ
- 1
SQLファイルを作る
queries/slow-orders.sqlにクエリを書きます。末尾のセミコロンは付けても付けなくても構いません。 - 2
スクリプトで計画を取る
./scripts/explain.sh queries/slow-orders.sqlを実行し、plans/slow-orders.txtを作ります。 - 3
計画と定義を読ませる
計画ファイルと、テーブル定義の出力を渡し、推定と実測の差が大きいノードから順に挙げさせます。
- 4
仮説を一つずつ確かめる
仮説ごとに、確かめるSQLを分けて出させます。まず統計の更新、次に索引の候補、という順です。
依頼の文は、見る場所を指定します。たとえば次のとおりです。
plans/slow-orders.txt を読んで、次の順で整理してください。
1. 各ノードの推定行数(rows)と実際の行数(actual rows)を表にする
2. loops が1より大きいノードは、actual time × loops で合計時間を出す
3. LIMIT で打ち切られたノードなど、ずれが「誤差ではない」ものは区別する
4. 仮説は最大3つ。それぞれに、確かめる手順を付ける
CREATE INDEX などのDDLは実行せず、文面の提案だけにしてください。3番は、次の節の内容に対応します。計画のずれがすべて問題ではないため、読む側に区別の基準を持たせておくのです。
計画の読み方: どこに原因が出るか
PostgreSQLのマニュアルは、実行計画の読み方で最初に見る場所を明確にしています。実測時間の単位はミリ秒で、コストは任意の単位です。数字は一致しません。最も重要なのは、推定行数が実際の行数に近いかどうかです。
推定行数と実際の行数のずれ
次の出力は、読み方の練習用に組み立てた架空の例です。実機で取った出力ではありません。
Nested Loop (cost=0.43..21034.10 rows=12 width=72)
(actual time=0.05..912.40 rows=48000.00 loops=1)
Buffers: shared hit=52000 read=6334
-> Seq Scan on orders o (cost=0.00..18334.00 rows=12 width=40)
(actual time=0.02..310.80 rows=48000.00 loops=1)
Filter: (status = 'pending'::text)
Rows Removed by Filter: 952000
Buffers: shared hit=2000 read=6334
-> Index Scan using customers_pkey on customers c
(cost=0.43..8.45 rows=1 width=32)
(actual time=0.012..0.012 rows=1.00 loops=48000)
Index Cond: (id = o.customer_id)見る順番は次のとおりです。
Seq Scan on ordersの推定はrows=12で、実際はrows=48000.00です。四桁の差がありますRows Removed by Filter: 952000から、約100万行を読んで4万8千行だけ残していると分かります- 内側の
Index Scanは1回あたり0.012msですが、loops=48000です。マニュアルの説明どおり、actual timeとrowsは1回あたりの平均なので、loopsを掛けて合計を出します。0.012 × 48000 ≒ 576msで、全体の912msの大半を占めます
推定が12行だったので、プランナーは「外側が少ないから入れ子ループが安い」と判断しています。実際は4万8千行あり、入れ子ループが重くなりました。原因の候補は、orders の統計が古いこと、status の偏りをプランナーが見積もれていないことなどです。どちらかは、この出力だけでは決まりません。
そこで、Claude Codeには断定ではなく、確かめる手順を出させます。たとえば ANALYZE orders; のあとにスクリプトを再実行し、推定行数が動くかを見ます。動けば統計が原因で、動かなければ見積もりの前提が別にあります。
ずれていても問題ではない場合
マニュアルは、推定と実測が合わなくても、何も壊れていないケースを挙げています。
- LIMITで打ち切られたノード: コストと行数は最後まで実行した想定で表示されますが、実際には必要な行数で止まります。誤差ではなく、表示の仕様です
- マージ結合: 片方の入力を読み切る前に止まることがあります。外側に重複キーがあると、内側を巻き戻して数え直すため、内側の実際の行数が、表の行数より大きく出ることがあります
- BitmapAndとBitmapOr: 実装上の制約で、実際の行数が常に0と表示されます
- 小さなテーブル: 1ページに収まる表は、索引があっても順次スキャンが選ばれるのが普通です
これらを知らないと、Claudeが「推定が外れているので統計を疑うべき」と、正常な出力まで問題扱いする可能性があります。前の節の依頼文に3番を入れたのは、この誤読を減らすためです。
そのほかに見る行
| 出力の行 | 読み取れること |
|---|---|
Buffers: shared hit=… read=… | 読み取れることhit はキャッシュで足りたブロック、read は読み込んだブロック |
Sort Method: … | 読み取れることメモリ内か、ディスクを使った並べ替えか |
Batches: …(Hash) | 読み取れること1を超えるとハッシュ表がメモリに収まらず、ディスクを使う |
Rows Removed by Filter | 読み取れること読んだが捨てた行数。大きいと、絞り込みに索引が効いていない可能性がある |
Planning Time / Execution Time | 読み取れること計画の作成と実行にかかった時間 |
BUFFERS はANALYZEと一緒に指定すると自動で有効になります。この記事のスクリプトで明示しているのは、読む側に意図を伝えるためです。
索引を足して取り直す
仮説の中に索引の追加が出たら、実行はあなたが行います。Claude Codeに出させるのは、文面と、取り直すときの比較の観点です。
# 索引を作る前の計画を保存し、名前を変えて残す
cp plans/slow-orders.txt plans/slow-orders.before.txt
# 検証用DBで索引を作る(実行は人が行う)
psql -X "$DATABASE_URL" -c "CREATE INDEX orders_status_idx ON orders (status);"
psql -X "$DATABASE_URL" -c "ANALYZE orders;"
# 同じSQLファイルで取り直す
./scripts/explain.sh queries/slow-orders.sql取り直したら、plans/slow-orders.before.txt と plans/slow-orders.txt を並べて渡し、次の観点で比べさせます。
Seq ScanがIndex ScanやBitmap Heap Scanに変わったかExecution Timeが下がったか。下がり方は一度の測定でなく、複数回の傾向で見るBuffersのreadが減ったか。hitとreadは、直前にキャッシュが温まっていたかでも変わる- 推定行数と実際の行数の差は縮まったか
測定には限界もあります。マニュアルによれば、EXPLAIN ANALYZEの時間はクライアントへの転送時間を含みません。時刻を何度も読むオーバーヘッドが、環境によっては小さくないとも書かれています。ノードごとの時間が要らず行数だけ見たいなら、TIMING OFF を使えます。出力の変換にかかる時間まで測りたいときは、SERIALIZE を指定します。
もう一つ、マニュアルは「結果を大きく違う状況に当てはめない」と注意しています。小さな表で出た計画は、大きな表には当てはまりません。検証用DBのデータ量が本番と桁違いに少ないと、索引が使われない計画になり、結論を誤ります。
落とし穴
索引を足したのに計画が変わらない
小さな表では順次スキャンが選ばれ続けます。表の大きさや統計を確かめる前に、索引が悪いと判断しないでください。ANALYZE を実行して統計を更新したか、検証用DBの行数が実際に近いかを、先に見ます。
MCPサーバーと使い分ける
計画の取得から索引の提案までを、MCPサーバーに任せる方法もあります。Postgres MCP Proの使い方は、索引提案とヘルスチェックを主題にしています。この記事の方法は、MCPを入れずにSQLファイルとスクリプトだけで済ませ、計画の読み方を自分の側に残す方法です。
設計の側から相談したい場合は、pg-aiguideでPostgresスキーマ設計を任せる3つの入れ方が参考になります。マネージドのPostgreSQLに接続する場合は、Cloud SQL for PostgreSQLプラグインの接続設定が先になります。
どの方法でも、EXPLAIN ANALYZE がクエリを実行する点は変わりません。接続先とロールの設計が、安全性の大半を決めます。
まとめ
実行計画の読解をClaude Codeに任せるときの要点は、取得の経路を先に絞ることです。更新系の文を BEGIN と ROLLBACK で包むスクリプトを入口にし、psql の直接実行は deny で止めます。止められない書き方が残るので、DBロールかサンドボックスで境界を作ります。
読解は、推定と実測のずれ、loops を掛けた合計時間、Rows Removed by Filter、Buffers の順に見るよう指定します。ずれのすべてが異常ではありません。LIMITやマージ結合の表示の癖は、先に伝えておきます。
索引を足したら、同じSQLファイルで計画を取り直し、前後を並べて比べます。取り直しまでやって初めて、仮説が当たったかどうかが分かります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。