
N+1問題とは - ORM で起きる原因と Eager Loading・JOIN・バッチ取得での対策
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
一覧画面を開くと妙に遅い。けれどスロークエリログを見ても、遅いクエリは1本も見当たらない。こういうときに疑うべきなのがN+1問題です。1本1本のクエリは速いのに、その本数が一覧の件数に比例して増えていくため、合計すると遅くなります。
この記事では、N+1問題が起きる仕組みを SQL ログで確認したうえで、なぜ遅いのか、JOIN と IN 句という2つの解き方の違い、主要な ORM ごとの対策 API、見つけ方、そして対策のやりすぎで起きる問題までを整理します。ORM の API 名は Laravel・Rails・Django・Prisma の各公式ドキュメントと、graphql/dataloader の README で確認したものだけを載せています。
N+1問題とは
N+1問題は、一覧を取得するクエリが1回、そのあと一覧の各行について関連データを取るクエリが N 回発行され、合計で 1 + N 回のクエリが走ってしまう状態のことです。
Laravel の公式ドキュメントにある例がわかりやすいので、そのまま借ります。Book モデルが author というリレーションを持っているとします。
use App\Models\Book;
$books = Book::all();
foreach ($books as $book) {
echo $book->author->name;
}このコードを実行したときに発行される SQL は、次のような形になります。
select * from books;
select * from authors where id = 1;
select * from authors where id = 2;
select * from authors where id = 3;
-- ... 本の冊数だけ続く
select * from authors where id = 25;公式ドキュメントでも「本が25冊あれば26回のクエリが実行される(本の取得1回 + 著者の取得25回)」と説明されています。Rails Guides にも同じ構図の例があり、Book.limit(10) を回して book.author.last_name を読むと合計11回のクエリになると書かれています。名前は「N+1」ですが、順番で言えば「1 + N」です。
なぜ起きるのか - 遅延読み込み(Lazy Loading)
N+1問題の直接の原因は、ORM の遅延読み込み(Lazy Loading)です。多くの ORM では、$book->author や book.author のようにリレーションをプロパティとして読んだ瞬間に、まだ読み込まれていなければその場で SQL を発行します。Laravel の公式ドキュメントも、プロパティとしてアクセスしたリレーションは初めてアクセスされるまで実際には読み込まれない、と説明しています。
これ自体は便利な仕組みです。必要になるまでデータを取らないので、使わない関連データの分だけ無駄が減ります。問題は、ループの中でアクセスすると、行ごとに1回ずつ SQL が飛ぶことです。
厄介なのは、コード上はただのプロパティアクセスにしか見えない点です。特に次のような場所で気づかれにくくなります。
- ビューテンプレートの中のループ(Blade、ERB、Django テンプレートなど)
- API のレスポンスを組み立てるシリアライザやリソースクラス
- 一覧の各行で呼ばれるモデルのアクセサやヘルパーメソッド
- GraphQL のリゾルバ(後述)
コントローラでは一覧を1回取っているだけに見えても、その先のテンプレートやシリアライザで関連データを読んでいれば N+1 は起きます。
なぜ遅いのか - 往復回数が積み上がる
1本1本のクエリは主キーでの検索なので、インデックスが効いていれば非常に速く終わります。それでも遅くなるのは、クエリ1本ごとにアプリケーションとデータベースの間で往復(ラウンドトリップ)が発生するからです。往復のたびに、ネットワーク越しの送受信、SQL の解析と実行計画の作成、結果のシリアライズ、ORM 側でのオブジェクト生成といった固定的なコストがかかります。
数字を仮定して考えてみます。以下は説明のための仮定です。
- 1往復あたりのコストを 1ms と仮定すると、一覧が100件なら N+1 では 101回で約101ms、対策後の2回なら約2ms
- 一覧が1,000件なら N+1 では約1,001ms、対策後は2回のまま(IN 句に渡す ID は増えますが往復は増えません)
- アプリケーションとデータベースが別ホストにあり、1往復が 1ms より大きければ、差はその分だけ広がります
ここで重要なのは、N+1 のコストは件数に比例して増えるということです。開発環境ではデータが10件しかなく、データベースも同じマシンにあるため、ほとんど遅さを感じません。本番でデータが増え、データベースが別ホストに置かれて初めて表面化する、というのが典型的なパターンです。
もう1つの特徴は、冒頭でも書いたようにスロークエリログに引っかかりにくいことです。スロークエリログは1本ごとの実行時間がしきい値を超えたものを記録する仕組みなので、1本は速いが本数が多いという N+1 は記録されません。1本のクエリが遅い場合の調べ方は 実行計画(EXPLAIN)の読み方 で扱いましたが、N+1 はそれとは別の観点、つまり1リクエストあたり何本のクエリが出ているかで見つける必要があります。
解き方は2つ - JOIN と IN 句による別クエリ
N+1 を解消する方法は、SQL のレベルで見ると大きく2つに分かれます。ORM の Eager Loading(事前読み込み)は、内部でこのどちらか、あるいは両方を使っています。
方法1: JOIN で1回にまとめる
SELECT books.*, authors.*
FROM books
LEFT OUTER JOIN authors ON authors.id = books.author_id;クエリは1回で済みます。ただし、1対多や多対多のリレーションを JOIN すると、親の行が子の数だけ重複して返ってきます。著者1人に本が50冊あれば、著者の列は50回繰り返されます。さらに1つの親に対して複数の「多」側のリレーション(例えば投稿に対するコメントとタグ)を同時に JOIN すると、結果の行数は掛け算で増えていきます。
Django の公式ドキュメントも、select_related が JOIN を使うことを説明したうえで、「多」側のリレーションを結合したときに結果セットがずっと大きくなるのを避けるため、select_related は外部キーと1対1の関係に限られている、と書いています。
方法2: IN 句で関連をまとめて別クエリにする
SELECT * FROM books;
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5);まず親を取り、その結果に含まれる外部キーを集めて、関連テーブルを IN 句で1回だけ引きます。クエリは2回になりますが、件数がいくら増えても2回のままです。行の重複も起きません。Laravel の with、Rails の preload、Django の prefetch_related はこちらの方式で、ORM が2回目の結果を親の各オブジェクトに振り分けてくれます。
ここで忘れがちなのが、2回目のクエリの検索列にインデックスがあるかです。authors.id は主キーなので問題ありませんが、1対多の子側を取る SELECT * FROM comments WHERE post_id IN (...) のようなクエリでは、post_id にインデックスがないとフルスキャンになります。インデックスが効く条件は データベースインデックス入門 にまとめています。
どちらを選ぶか
| 観点 | JOIN(1クエリ) | IN 句による別クエリ |
|---|---|---|
| クエリ回数 | 1回 | リレーションごとに1回ずつ追加 |
| 行の重複 | 1対多・多対多では親が子の数だけ重複 | 起きない |
| 転送データ量 | 重複分だけ増える | 必要な分だけ |
| 関連テーブルの列で絞り込み・並び替え | できる | 2回目のクエリ側でしかできない |
| 向いている関係 | 多対1、1対1 | 1対多、多対多 |
大まかには、多対1や1対1(本から著者を引くような向き)は JOIN、1対多や多対多(著者から本の一覧を引くような向き)は IN 句、と覚えておくと判断しやすくなります。関連テーブルの列で親を絞り込みたい場合は JOIN が必要です。
ORM 別の対策
ここからは主要な ORM ごとに、Eager Loading の書き方と、N+1 を検出・禁止する仕組みを見ていきます。
Laravel Eloquent
基本は with です。冒頭の例は次のように書き換えると、クエリが2回になります。
$books = Book::with('author')->get();
foreach ($books as $book) {
echo $book->author->name;
}select * from books
select * from authors where id in (1, 2, 3, 4, 5, ...)公式ドキュメントで確認できる主な書き方は次のとおりです。
// 複数のリレーション
$books = Book::with(['author', 'publisher'])->get();
// ネストしたリレーション(ドット記法)
$books = Book::with('author.contacts')->get();
// 取得する列を絞る(id と外部キーは必ず含める)
$books = Book::with('author:id,name,book_id')->get();
// 取得済みのコレクションに後から読み込む(Lazy Eager Loading)
$users->loadMissing(['comments', 'posts']);
// 件数だけ欲しいときはモデルを読み込まずに数える
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}列を絞る書き方について、公式ドキュメントは「id 列と関連する外部キー列を必ず含めること」と注意しています。外部キーがないと、ORM が2回目のクエリの結果を親に振り分けられないためです。
N+1 を見逃さないための仕組みとして、遅延読み込みの禁止があります。AppServiceProvider の boot メソッドで Model::preventLazyLoading を呼ぶと、Eager Loading していないリレーションへのアクセスで例外が投げられます。公式ドキュメントの例では、本番以外でだけ有効にしています。
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}例外ではなくログに残したい場合は、Model::handleLazyLoadingViolationUsing で違反時の処理を差し替えられます。
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
$class = $model::class;
info("Attempted to lazy load [{$relation}] on model [{$class}].");
});また、Laravel 13.x の公式ドキュメントにはAutomatic Eager Loadingという機能が載っています。Model::automaticallyEagerLoadRelationships() を boot で呼ぶとアプリケーション全体で、コレクションに対して withRelationshipAutoloading() を呼ぶとそのコレクションだけで、リレーションの読み込みを自動化します。ドキュメント上はbetaと記載されているため、使う場合は対象のバージョンのドキュメントで現状を確認してください。導入されたバージョンは本記事では確認していません。
Rails(Active Record)
Rails には Eager Loading のメソッドが3つあり、Rails Guides では次のように説明されています。
preload: リレーションごとに別クエリを発行する(IN 句方式)。関連テーブルに条件を付けることはできないeager_load:LEFT OUTER JOINを使って1クエリで取る(JOIN 方式)。関連テーブルに条件を付けられるincludes: 状況に応じてpreloadとeager_loadを使い分ける。Guides は基本的にincludesを推奨している
books = Book.includes(:author).limit(10)
books.each do |book|
puts book.author.last_name
endSELECT books.* FROM books
LIMIT 10
SELECT authors.* FROM authors
WHERE authors.id IN (1,2,3,4,5,6,7,8,9,10)SELECT "books"."id" AS t0_r0, "books"."title" AS t0_r1, ... FROM "books"
LEFT OUTER JOIN "authors" ON "authors"."id" = "books"."author_id"
LIMIT 10includes は、関連テーブルの列に対する条件をハッシュで渡すと JOIN 方式に切り替わります。SQL の文字列で条件を書く場合は references で関連テーブルを明示する必要があります。
# ハッシュで条件を渡すと LEFT OUTER JOIN になる
Author.includes(:books).where(books: { out_of_print: true })
# 文字列で条件を書く場合は references が必要
Author.includes(:books).where("books.out_of_print = true").references(:books)N+1 を禁止する仕組みは strict_loading です。User.strict_loading.first のように使うと、そのレコードから Eager Loading していないリレーションを読んだ時点で ActiveRecord::StrictLoadingViolationError が発生します。レコード単位の strict_loading! もあります。アプリケーション全体で有効にするには config.active_record.strict_loading_by_default = true を、例外ではなくログに出すには config.active_record.action_on_strict_loading_violation に :log を設定します。
Django
Django の Eager Loading は select_related と prefetch_related の2つで、JOIN 方式と IN 句方式がはっきり分かれています。
select_related: SQL の JOIN で関連オブジェクトを同じクエリで取る。外部キーと1対1の関係(単一の値になる関係)に限られるprefetch_related: 関連ごとに1回の追加クエリで取り、Python 側で結合する。多対多や逆方向の外部キーにも使える
# JOIN で author と、その先の hometown まで1クエリで取る
b = Book.objects.select_related("author__hometown").get(id=4)
p = b.author # データベースにアクセスしない
c = p.hometown # データベースにアクセスしない
# 1対1/多対1 は select_related、その先の多対多は prefetch_related
Restaurant.objects.select_related("best_pizza").prefetch_related("best_pizza__toppings")最後の例について公式ドキュメントは、best_pizza が select_related で取得済みであることを prefetch_related が検知して再取得しないため、クエリは2回で済むと説明しています。
Django 6.1 ではfetch modeという仕組みが追加されました。読み込まれていないフィールドにアクセスしたときの振る舞いを、QuerySet.fetch_mode() で切り替えられます。
FETCH_ONE: 既定。アクセスしたインスタンスの分だけ取得する(従来の動き)FETCH_PEERS: 同じQuerySetから来た全インスタンスの分をまとめて取得する。必要になった時点でprefetch_related()が走るようなもので、リリースノートは「N+1 問題の多くを2回のクエリに減らせる」と説明しているFETCH_RAISE:FieldFetchBlocked例外を投げる。意図しないクエリを防ぎたい箇所で使う
from django.db import models
books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
print(book.author.name) # 本の取得と著者の取得の2クエリで済む公式ドキュメントによると、fetch mode が効くのは ForeignKey、OneToOneField とその逆方向、defer() / only() で遅延させたフィールド、汎用リレーションです。多対多や逆方向の外部キーのような関連マネージャ経由のクエリには効かないため、そちらは引き続き prefetch_related で対処します。
なお Django 6.1 では、引数なしの select_related()(null でない外部キーをすべて辿る)が非推奨になりました。公式ドキュメントも、必要以上に複雑なクエリと多くのデータを返しやすいとして、多くの場合は推奨しないと書いています。
Prisma
Prisma ORM 7 では、include(または select)でリレーションを一緒に取得します。逆に、ユーザーの一覧を取ったあとループの中で投稿を findMany するような書き方をすると N+1 になります。
const usersWithPosts = await prisma.user.findMany({
relationLoadStrategy: "join", // または "query"
include: {
posts: true,
},
});relationLoadStrategy は、関連の読み込み方を選ぶトップレベルのオプションです。
join: データベース側で結合し、1回の SQL でネストしたデータを返す。Prisma のブログによると、PostgreSQL では JSON 集約と LATERAL JOIN を使うquery: テーブルごとにクエリを送り、アプリケーション側で結合する
このオプションは schema.prisma の generator ブロックで previewFeatures = ["relationJoins"] を指定したときに使え、対象は PostgreSQL、CockroachDB、MySQL です。フラグを有効にすると既定は join になり、フラグがなければ常にアプリケーション側で結合する方式になります。Prisma ORM 7 のドキュメントでもプレビュー機能として扱われており、本記事の執筆時点で GA(一般提供)になったことを示す公式の記載は確認できませんでした。
補足: Prisma の現行ドキュメントは Prisma ORM 8 を前提に書かれており、クエリの書き方が .include("author") のようなメソッドチェーンに変わっています。リレーションは親と同じ1本の SQL の中で相関サブクエリを使って読む、と説明されています。Prisma ORM 8 の提供状況(GA かどうか)は本記事では未確認のため、移行を検討する場合は公式の移行ガイドを確認してください。
GraphQL と DataLoader
GraphQL は構造上 N+1 が起きやすい仕組みです。リゾルバはフィールドごとに独立して呼ばれるため、posts の一覧を返したあと、各 post の author フィールドのリゾルバが投稿の件数だけ呼ばれ、それぞれがデータベースを引いてしまいます。
この問題に対する定番の解決策が graphql/dataloader です。README によると、DataLoader は1回の実行フレーム(イベントループの1 tick)の中で発生した個々の load 呼び出しをまとめ、要求されたすべてのキーを渡してバッチ関数を1回だけ呼びます。
const DataLoader = require("dataloader");
// バッチ関数: キーの配列を受け取り、値の配列を返す Promise を返す
async function batchGetUsers(ids) {
const rows = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
const byId = new Map(rows.map((row) => [row.id, row]));
// キーと同じ長さ・同じ順番で返す(見つからないキーは null などで埋める)
return ids.map((id) => byId.get(id) ?? null);
}
// リクエストごとに新しいインスタンスを作る
function createLoaders() {
return { user: new DataLoader(batchGetUsers) };
}
// リゾルバ側では1件ずつ load するだけでよい
const resolvers = {
Post: {
author: (post, _args, context) => context.loaders.user.load(post.authorId),
},
};バッチ関数には README で決められた約束があります。
- 返す値の配列は、受け取ったキーの配列と同じ長さであること
- 値の配列の各要素は、キーの配列の同じ位置に対応していること
SQL の IN 句(PostgreSQL なら = ANY(...))の結果は、キーの順番どおりには返ってきません。上の例のように、いったん ID をキーにした Map に入れてからキーの順に並べ直すのが定石です。
もう1つ大事なのがキャッシュの範囲です。DataLoader は同じキーの load を記憶しますが、README は「DataLoader のキャッシュは Redis や Memcache などの共有キャッシュを置き換えるものではない」とし、リクエストごとに新しい DataLoader を作ることを推奨しています。複数のユーザーで1つのキャッシュを共有すると、別のユーザーのデータが見えてしまう危険があるためです。アプリケーション全体で使うキャッシュの設計は キャッシュ戦略の基本 を参照してください。
DataLoader の考え方は GraphQL 以外にも使えます。Django の FETCH_PEERS や Laravel の Automatic Eager Loading も、「1件ずつ要求されたものを、まとめて取りに行く」という点では同じ発想です。
見つけ方
N+1 は1本のクエリが遅いわけではないので、リクエスト単位のクエリ本数と、値だけが違う同じ形の SQL の繰り返しを探します。
開発環境のツール
| 対象 | ツール | 確認できること |
|---|---|---|
| Laravel | Laravel Debugbar | 発行された全クエリとバインド値、実行時間(QueryCollector) |
| Rails | bullet | N+1、不要な Eager Loading、counter cache が必要な箇所の通知 |
| Django | django-debug-toolbar | SQL パネルで各クエリの実行時間と EXPLAIN へのリンク |
bullet は、N+1 を検出したときに通知と一緒にスタックトレースを出してくれるので、どのコードが原因かをすぐに追えます。Bullet.raise = true にすると検出時にエラーを投げるため、README でもテストを失敗させる用途が紹介されています。
Laravel Debugbar は、APP_DEBUG が true で、環境が production でも testing でもないときに有効になります。README は本番環境にはインストールしないことを強く推奨しています。
テストで本数を固定する
一度直した N+1 が再発しないようにするには、テストでクエリ本数を検査するのが確実です。
- Django:
TransactionTestCase.assertNumQueriesで、処理中に発行されたクエリ本数を検証できる - Rails:
strict_loadingを有効にしておけば、遅延読み込みが起きた時点で例外になる - Laravel: テスト環境でも
Model::preventLazyLoadingが有効になる条件にしておけば、違反時に例外になる
前の節で書いたように、件数が少ないテストデータでは N+1 があっても速く終わります。実行時間ではなく、クエリ本数や遅延読み込みの有無で検査するのがポイントです。テストデータが1件だけだと 1 + 1 = 2回で、Eager Loading した場合と本数が変わらないこともあるため、一覧のテストでは2件以上のデータを用意しておきます。
本番環境では APM とトレース
本番では、APM(アプリケーション性能監視)や分散トレースのスパンを見ると、1つのリクエストの中に同じ形の SQL が何十本も並んでいる様子がそのまま見えます。リクエストあたりのクエリ本数をメトリクスとして記録しておけば、件数の増加に伴って本数が増えていくエンドポイントも見つけられます。ログ・メトリクス・トレースの使い分けは オブザーバビリティ入門 で整理しています。
Eager Loading しすぎの問題
N+1 を恐れて、何でもかんでも Eager Loading すればよいわけではありません。逆方向の問題もあります。
- 使わない関連を読む: 一覧画面では著者名しか表示しないのに、著者・出版社・タグ・コメントまで毎回読み込むと、クエリ本数もデータ量も無駄に増えます。bullet が「不要な Eager Loading」を検出対象にしているのはこのためです
- JOIN による行の爆発: 1対多のリレーションを複数 JOIN すると、結果の行数が掛け算で増えます。親100件、子が平均20件ずつのリレーションを2つ JOIN すれば、単純計算で 100 × 20 × 20 = 40,000行になります(仮の数字です)。この場合は IN 句方式に切り替えます
- 全列の取得: 関連テーブルの大きなテキスト列や JSON 列まで読む必要がなければ、Laravel の
author:id,name,book_idのように列を絞ります - 件数のためにモデルを読む: コメント数を表示するだけなのに、コメントを全件 Eager Loading して数えるのは無駄です。Laravel の
withCountや、SQL のCOUNTとGROUP BYを使って、データベース側で集計します
対策の基本は、その画面や API が実際に使う関連と列だけを読むことです。「とりあえず全部 with しておく」は、N+1 を別の問題に置き換えているだけになりがちです。
ページネーションとの組み合わせ
一覧画面にはたいていページネーションが付きます。Eager Loading との組み合わせで気をつけたい点が2つあります。
1つ目は、Eager Loading はページ単位で効くことです。1ページ20件なら、関連を取る IN 句に渡る ID も20件程度です。全件を取ってからアプリケーション側でページを切ると、IN 句が巨大になり、読み込むデータ量も膨らみます。必ずデータベース側で LIMIT を掛けた親に対して Eager Loading します。
2つ目は、1対多を JOIN した SQL に LIMIT を掛けると、親の件数ではなく結合後の行数で切られることです。
-- 投稿を10件取りたいつもりが、「投稿 x コメント」の行が10行返る
SELECT posts.*, comments.*
FROM posts
LEFT OUTER JOIN comments ON comments.post_id = posts.id
ORDER BY posts.id DESC
LIMIT 10;
-- 親だけを先に LIMIT で取り、子は IN 句で取る2段階にする
SELECT * FROM posts ORDER BY id DESC LIMIT 10;
SELECT * FROM comments WHERE post_id IN (110, 109, 108, 107, 106, 105, 104, 103, 102, 101);1件目の投稿にコメントが10件あれば、それだけで LIMIT 10 を使い切ってしまいます。生の SQL を書く場合は、親を先にページングしてから子を IN 句で取る2段階が安全です。ORM の JOIN 方式(Rails の eager_load など)と LIMIT を組み合わせたときの SQL がどうなるかは ORM の実装によるため、実際に発行される SQL をログで確認してください。
ページネーションそのものの方式(OFFSET 方式とカーソル方式)の違いは ページネーション入門 で扱っています。OFFSET が深くなるほど遅くなる問題は N+1 とは別の原因なので、両方を切り分けて考えます。
対策の選び方
最後に、N+1 に出会ったときの手順をまとめます。
- クエリ本数を数える。Debugbar・bullet・django-debug-toolbar・APM などで、1リクエストあたりの本数と、同じ形の SQL の繰り返しを確認する
- どの関連のアクセスが原因かを特定する。テンプレート、シリアライザ、リゾルバ、アクセサの中のループを疑う
- 関係の向きで方式を選ぶ。多対1・1対1は JOIN(
select_related、eager_load)、1対多・多対多は IN 句(with、preload、prefetch_related) - 読む列と関連を絞る。件数だけなら集計、表示しない関連は読まない
- IN 句の対象列にインデックスがあるか確認する
- 再発を防ぐ。
preventLazyLoading・strict_loading・FETCH_RAISE・assertNumQueriesを開発環境とテストで有効にする
N+1 は、ORM が「必要になったら取りに行く」ように作られていることの裏返しです。仕組みを知っていれば、SQL ログを見て「同じ形のクエリが件数分並んでいる」と気づいた時点で原因の見当がつきます。ORM のコードを書くときも、その裏で何本の SQL が出ているかを意識しておくと、本番でデータが増えてから慌てることが減るはずです。
参考資料
- Laravel: Eloquent Relationships(Eager Loading / Preventing Lazy Loading)
- Laravel: Eloquent(preventLazyLoading)
- Rails Guides: Active Record Query Interface(Eager Loading Associations / strict_loading)
- Django: QuerySet API reference(select_related / prefetch_related / fetch_mode)
- Django: Fetch modes
- Django 6.1 release notes
- Django: Database access optimization
- Django: Testing tools(assertNumQueries)
- Prisma ORM v7: Relation queries(relationLoadStrategy)
- Prisma Blog: Prisma ORM Now Lets You Choose the Best Join Strategy (Preview)
- Prisma: Relations and joins(Prisma ORM 8)
- graphql/dataloader
- flyerhzm/bullet
- fruitcake/laravel-debugbar
- django-commons/django-debug-toolbar


