Use Cases (更新: 2026/6/1)

Claude CodeでSQL最適化を実践する完全ガイド

Claude CodeでSQL最適化を進める手順を、EXPLAIN、索引設計、N+1対策、テストまで解説。

Claude CodeでSQL最適化を実践する完全ガイド

SQL最適化とは何を直す作業か

SQL最適化とは、同じ結果をより少ない読み取り・少ない待ち時間で返すようにクエリ、索引、データ取得方法を整える作業です。単に「速いSQLを書こう」ではありません。アプリの画面、ORMの呼び出し、DBの実行計画、本番データ量、リリース後の監視までをひと続きで扱わないと、開発環境だけ速い改善で終わります。

Claude Codeを使う価値は、SQL文だけでなく、呼び出し元のTypeScript、PrismaやRailsのORM設定、マイグレーション、テスト、ログ設定を横断して読ませられる点にあります。Masaが業務システムで見てきた遅延の多くは、1本の難しいSQLではなく、一覧画面のN+1、不要なSELECT *、OFFSETページング、索引の順序ミス、集計を毎回同期実行する設計が積み重なったものでした。

この記事ではPostgreSQLを中心に説明し、MySQLにも触れます。実行計画の読み方は公式ドキュメントのPostgreSQL: Using EXPLAINPostgreSQL: EXPLAINPostgreSQL: IndexesPostgreSQL: Multicolumn IndexesMySQL 8.4: Using EXPLAINも必ず確認してください。

関連して、設計から見直すならClaude Codeでデータベース設計、安全に索引を追加するならデータベースマイグレーション、ORM呼び出しの改善はPrisma ORM活用、アプリ全体の速度改善はパフォーマンス最適化が役立ちます。

使いどころを3つに分ける

最初にユースケースを分けると、Claude Codeへの依頼が具体的になります。

ユースケースよくある症状主な改善注意点
管理画面の一覧2ページ目以降が遅い、検索でCPUが上がる複合索引、キーセットページング、取得列削減条件の組み合わせが多すぎると索引が増える
EC・SaaSのユーザー画面商品や案件の一覧がPV増で遅いWHERE/ORDER BYに合う索引、キャッシュ、非同期集計在庫や権限条件を落とすと事故になる
ダッシュボード集計月次・日次集計が毎回重い集計テーブル、マテリアライズ、バッチ更新リアルタイム性の要件を先に決める

「何秒を何秒にしたいのか」「どの画面の何パーセンタイルを守るのか」を決めると、Claude Codeはコード差分を小さく保てます。私はまずp95 300ms、一覧API 1リクエストあたりSQL 5本以内、といったクエリ予算を置くことが多いです。

Claude Codeへの依頼例:
この一覧APIのSQL最適化をしてください。
目標: p95 300ms以内、SQL 5本以内、返却JSONは互換維持。
見るもの: src/routes/admin/orders.ts, prisma/schema.prisma, migrations, slow-query.log。
やること: 遅いSQL特定、EXPLAIN ANALYZEの読み取り、索引案、N+1除去、回帰テスト追加。
制約: 本番互換を壊さない。不要な全面リファクタはしない。

現場で失敗しない依頼の順番

Claude Codeに最初から「このSQLを速くして」とだけ渡すと、索引追加だけで終わりがちです。しかし本当に知りたいのは、どの画面で、どのユーザー操作の、どの待ち時間が、どのデータ量で問題になっているかです。たとえば同じ注文一覧でも、管理者が全件検索する画面と、ログインユーザーが自分の注文だけ見る画面では、必要な索引も許容できるキャッシュも違います。

私が実務で使う順番は、まず画面とAPIを固定し、次に本番に近いログから遅いSQLを抜き出し、EXPLAINで事実を確認し、その後で索引、SQL、ORM、画面要件を触ります。ここを逆にすると、たまたま開発環境では速いが本番では効かない変更や、速くなった代わりに権限条件を落としてしまう変更が入りやすくなります。Claude Codeには「改善案を出す前に、確認すべき前提を箇条書きにして」と一度止めるのが有効です。

もう一つ大事なのは、DBだけを責めないことです。遅い原因がSQLに見えても、実際には画面側が不要な項目を要求していたり、APIが同じ集計を複数回呼んでいたり、ページング仕様が「最後のページへ直接移動したい」になっているせいでOFFSETを捨てられないことがあります。Claude CodeにUI、API、schema、migration、testをまとめて読ませると、この境界の問題を発見しやすくなります。

flowchart LR
  A["遅い画面を特定"] --> B["slow queryを収集"]
  B --> C["EXPLAINで事実確認"]
  C --> D["索引・SQL・ORMを修正"]
  D --> E["予算テストを追加"]
  E --> F["段階リリースと監視"]

手順1: スロークエリを捕まえる

推測で索引を作る前に、実際に遅いSQLを捕まえます。PostgreSQLならlog_min_duration_statementpg_stat_statements、アプリ側ならORMのクエリログが入口です。以下はローカル検証用のPostgreSQL設定例です。本番で変更する場合は、ログ量と個人情報に注意してください。

-- PostgreSQLで拡張を有効化できる環境向け
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
  query,
  calls,
  ROUND(total_exec_time::numeric, 2) AS total_ms,
  ROUND(mean_exec_time::numeric, 2) AS mean_ms,
  rows
FROM pg_stat_statements
WHERE query NOT ILIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 10;

Prismaなら開発環境でクエリログを出せます。これは実在するPrisma Clientの設定です。

import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient({
  log: [
    { emit: "event", level: "query" },
    { emit: "stdout", level: "error" },
    { emit: "stdout", level: "warn" },
  ],
});

prisma.$on("query", (event) => {
  if (event.duration > 100) {
    console.log({
      durationMs: event.duration,
      query: event.query,
      params: event.params,
    });
  }
});

ここでClaude Codeに「ログから同じパターンのSQLをまとめて、画面名、呼び出し元、平均時間、件数、推定原因を表にして」と依頼します。ログをそのまま貼ると秘密情報が混ざるので、メールアドレスやトークンは先にマスクします。

手順2: EXPLAINとEXPLAIN ANALYZEを読む

遅いSQLが見つかったら、EXPLAINで予定される実行計画を見ます。実データで安全に測れる環境ならEXPLAIN (ANALYZE, BUFFERS)を使います。ANALYZEは実際にSQLを実行するため、UPDATEDELETEには安易に使わないでください。

EXPLAIN (ANALYZE, BUFFERS)
SELECT
  o.id,
  o.created_at,
  o.status,
  c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
  AND o.created_at >= DATE '2026-01-01'
ORDER BY o.created_at DESC, o.id DESC
LIMIT 50;

見るポイントは、Seq Scanが悪いと決めつけないことです。小さいテーブルなら全表走査の方が速い場合があります。重要なのは、推定行数と実行行数が大きくずれていないか、Sortが巨大でないか、Buffersの読み取りが多すぎないか、JOINの順序が妥当かです。

Claude Codeには、実行計画を貼って次のように聞きます。

このEXPLAIN ANALYZEを、ボトルネック、推定行数のズレ、索引候補、SQL書き換え候補に分けてレビューしてください。
SQLの返却結果は変えないでください。

手順3: 複合索引を設計する

索引は「増やすほど速い」ものではありません。読み取りは速くなる一方で、書き込み、ストレージ、VACUUM、マイグレーション時間のコストが増えます。基本は、等価条件、範囲条件、並び替え、JOIN条件の順に、実際のクエリに合わせて設計します。

-- paidの注文を新しい順に50件取る一覧向け
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_status_created_id
  ON orders (status, created_at DESC, id DESC);

-- 顧客別の注文履歴向け
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_created
  ON orders (customer_id, created_at DESC);

PostgreSQLではCREATE INDEX CONCURRENTLYを使うと書き込みブロックを抑えられますが、通常のトランザクション内では実行できません。マイグレーションツールによって扱いが違うので、リリース手順に必ず明記します。

Claude Codeには「既存の索引一覧、対象SQL、EXPLAIN結果を渡す」ことが大事です。渡さずに頼むと、似た索引を重複提案しがちです。

SELECT
  indexname,
  indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename = 'orders'
ORDER BY indexname;

手順4: N+1をなくす

N+1は、一覧1本の取得後に、各行ごとに関連データを取りに行く問題です。100件の注文に対して顧客を100回取りに行くと、DB時間だけでなくネットワーク待ちも増えます。

// 悪い例: 注文ごとに顧客を取得している
const orders = await prisma.order.findMany({
  take: 100,
  orderBy: { createdAt: "desc" },
});

const rows = [];
for (const order of orders) {
  const customer = await prisma.customer.findUnique({
    where: { id: order.customerId },
  });
  rows.push({ ...order, customerName: customer?.name ?? "" });
}
// 改善例: 必要な列だけを一括取得する
const orders = await prisma.order.findMany({
  take: 100,
  orderBy: { createdAt: "desc" },
  select: {
    id: true,
    createdAt: true,
    status: true,
    totalAmount: true,
    customer: {
      select: {
        id: true,
        name: true,
      },
    },
  },
});

include: { customer: true }でも動きますが、不要な列まで返すことがあります。Claude Codeには「JSON互換を保ちつつ、必要列だけに絞って」と指示すると、画面に必要なデータを読みながら差分を作れます。

手順5: OFFSETページングをキーセットに変える

OFFSET 10000 LIMIT 50は、前の10000件を読み飛ばす必要があり、深いページほど遅くなります。無限スクロールや時系列一覧なら、最後に見たcreated_atidをカーソルにするキーセットページングが有効です。

-- 最初のページ
SELECT id, created_at, status, total_amount
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50;

-- 次のページ: 前ページ最後の値を渡す
SELECT id, created_at, status, total_amount
FROM orders
WHERE status = 'paid'
  AND (created_at, id) < (TIMESTAMP '2026-05-01 10:30:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 50;

このSQLには先ほどの(status, created_at DESC, id DESC)索引が合います。注意点は、並び順が安定するように同時刻のタイブレークとしてidを入れることです。

手順6: 集計は同期実行から分離する

ダッシュボードで毎回全期間の売上を集計すると、PVが増えた瞬間にDBが詰まります。リアルタイム性が不要なら、日次の集計テーブルに逃がします。

CREATE TABLE IF NOT EXISTS daily_sales_summary (
  sales_date date PRIMARY KEY,
  order_count integer NOT NULL,
  revenue numeric(12, 2) NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO daily_sales_summary (sales_date, order_count, revenue)
SELECT
  created_at::date AS sales_date,
  COUNT(*) AS order_count,
  SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid'
  AND created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY created_at::date
ON CONFLICT (sales_date) DO UPDATE
SET
  order_count = EXCLUDED.order_count,
  revenue = EXCLUDED.revenue,
  updated_at = now();

毎分必要なのか、1日1回で十分なのかで設計は変わります。Claude Codeには「この数字は何分遅れまで許容か」を明記してください。速さだけを優先すると、業務上必要な正確性を落とす危険があります。

手順7: クエリ予算と回帰テストを置く

一度速くしても、別の開発で戻ることがあります。予算をテストにします。ユニットテストで実行計画の完全一致を見るのは壊れやすいので、まずはAPIが発行するSQL本数、返却互換、主要ケースの時間を守ります。

import { PrismaClient } from "@prisma/client";
import { describe, expect, it } from "vitest";

it("keeps the order list within the query budget", async () => {
  const queries: string[] = [];
  const prisma = new PrismaClient({
    log: [{ emit: "event", level: "query" }],
  });

  prisma.$on("query", (event) => {
    queries.push(event.query);
  });

  await prisma.order.findMany({
    take: 50,
    orderBy: [{ createdAt: "desc" }, { id: "desc" }],
    select: {
      id: true,
      createdAt: true,
      status: true,
      customer: { select: { id: true, name: true } },
    },
  });

  await prisma.$disconnect();
  expect(queries.length).toBeLessThanOrEqual(2);
});

よくある落とし穴

  • SELECT *で不要な大きい列まで読む。本文、JSON、画像メタデータは一覧では避ける。
  • 低カーディナリティの列だけに索引を貼る。status単体のように値の種類が少ない列は効きにくい。
  • LIKE '%keyword%'を通常のB-tree索引で速くしようとする。全文検索や専用索引を検討する。
  • 本番より小さいデータでEXPLAINして判断する。10万行と1000万行では計画が変わる。
  • 索引追加をピーク時間に流す。ロック、レプリケーション遅延、ディスク増加を確認する。
  • ORMの便利なincludeで巨大な関連を丸ごと返す。

Claude Codeに任せるロールアウトチェックリスト

1. 対象APIと画面を特定したか
2. 遅いSQLをログまたはpg_stat_statementsで確認したか
3. EXPLAINまたはEXPLAIN ANALYZEを改善前後で保存したか
4. 既存索引と重複しない索引設計になっているか
5. N+1、OFFSET、不要列、同期集計を確認したか
6. 返却JSONの互換性をテストしたか
7. SQL本数または時間の回帰テストを追加したか
8. 索引追加のロック、所要時間、ロールバック手順を書いたか
9. 本番リリース後に見るメトリクスを決めたか

このリストをPR本文に入れて、Claude Codeに「未確認項目をレビューして」と依頼すると、作業漏れが減ります。特に索引追加はコードレビューだけでは危険を見落としやすいので、DBサイズ、レプリカ、バックアップ、マイグレーション方式までセットで確認します。

まとめ

SQL最適化は、1本のクエリを職人芸で短くする作業ではありません。遅いSQLを捕まえ、EXPLAINで事実を見て、索引と取得方法を変え、N+1やページングを直し、集計を分離し、予算とテストで再発を防ぐ作業です。Claude Codeはこの流れをリポジトリ横断で進める相棒になりますが、最終判断には実データ、実行計画、業務要件が必要です。

ClaudeCodeLabでは、Masaが実務で使っているClaude CodeのSQLレビュー手順、PRチェックリスト、研修向け演習を整理しています。自社の管理画面やSaaSで「DBが遅いが原因が見えない」状態なら、研修やスポット相談で、実際のログとコードを見ながら改善手順を一緒に組み立てられます。

この記事で紹介した内容を実際に試した結果、私の検証用注文一覧では、N+1除去と(status, created_at, id)の複合索引だけでSQL本数が101本から1本に減り、深いページの待ち時間も体感できるほど改善しました。一方で、索引を増やしすぎたテーブルでは書き込みが重くなったため、使われない索引を削るレビューも同じくらい重要だと再確認しました。

#Claude Code #SQL #データベース #パフォーマンス #最適化
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。