N+1問題(エヌプラスワン問題)とは、一覧を1回のクエリで取ったあと、各行に紐づく関連データを1件ずつ取りに行き、クエリが親の件数だけ増えてしまう性能問題です。N+1クエリ問題とも呼ばれます。ORMではプロパティ参照の背後でSQLが発行されるため、コードだけではN+1を見落としやすくなります。この記事では、Django 6.1.2とRails 8.1.4(Active Record)で実際に発行されるクエリ数を数えながら、仕組み、検出方法、ORM別の対策、そして事前ロードを入れたのに直っていない典型例を確認します。
まとめ:N+1問題の正体と対策の順序
- 正体は「親を取る1回+子を取るN回」のクエリ。Django 6.1.2とSQLite(インメモリ)で書籍2,000件を回すと2,001クエリ・約1.7秒、
select_relatedを付けると1クエリ・約55ミリ秒でした(中央値で約30倍)。 - 個々のSQLが記録条件を満たさない場合、スロークエリログだけではN+1を見逃します。開発時は
django-debug-toolbar(8.0.0)やbullet(8.3.1)、本番はAPMで「1リクエストあたりのクエリ本数」を見ます。 - 対策は事前ロードです。Djangoは
select_relatedとprefetch_related、Railsはincludes・preload・eager_load、Laravelはwith、HibernateはJOIN FETCHや@BatchSizeが該当します。 - 事前ロードしても、あとから関連に条件を付けたり件数をSQLで数えたりすると元に戻ります。Rails 8.1.4では
preloadのあとにcountを呼ぶだけで、2本のはずが202本になりました。 - 再発防止は、Railsの
strict_loading、LaravelのpreventLazyLoading、DjangoのassertNumQueriesでクエリ数をテストに書き込む方法が確実です。
N+1問題の仕組み:1回+N回でクエリが積み上がる流れ
発行されるSQLの並びと、たとえで見る非効率
書籍一覧に著者名を並べる画面をORMで素直に書くと、次のようになります。
for book in Book.objects.all():
print(book.title, book.author.name)
書籍一覧を取る1本に加え、ループのたびにbook.authorを解決する1本が走ります。流れるSQLは次の形です(テーブル名は簡略化した模式です)。
-- 1本目: 親の一覧
SELECT "book"."id", "book"."title", "book"."author_id" FROM "book";
-- 2本目以降: ループのたびに1本ずつ(これが N 本)
SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 1;
SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 2;
-- ...
-- select_related を付けると、これ1本で済む
SELECT "book"."id", "book"."title", "author"."id", "author"."name"
FROM "book" INNER JOIN "author" ON ("book"."author_id" = "author"."id");
買い物にたとえると、10品目のメモを持たずに店へ行き、1品買うたびに家へ戻って次の品目を確かめるようなものです。店で使う時間(1本ごとのSQL)は短くても、往復の回数が品目の数だけ増えます。この例の関連取得は主キー検索ですが、個々のSQLを速くしても、件数に比例して問い合わせが増える構造は残ります。本数そのものを減らすしかない、というのがこの問題の性格です。
ORMで頻発するのは、関連オブジェクトを遅延ロード(参照された時点でクエリを発行する方式)で実装しているためです。book.authorは文法上ただの属性参照ですが、内部でSQLが1本走ります。テンプレートでbook.author.nameと書いた場合も同じで、ビューのコードを読んでも原因が見つかりません。遅延ロードを含むORMの仕組み自体はORMとは?O/Rマッパーの仕組み・メリットとデメリットで扱っています。
読み方と呼び名、N+1冗長構成との違い
読み方は「エヌプラスワン」です。日本語圏では「N+1問題」「N+1クエリ問題」がほぼ同義で使われ、英語では「N+1 query problem」「N+1 selects problem」と書かれます。親の1回を先に数えて「1+N問題」と書く資料もありますが、指すものは同じです。
紛らわしいのが、インフラの「N+1冗長構成」です。必要な台数Nに予備を1台足して故障に備える設計を指し、クエリの問題とは無関係です。「IT用語でN+1」と調べてサーバー台数の話が出てきたら、こちらの意味です。
何が問題か:書き方別の発行クエリ数と所要時間
Django 6.1.2(Python 3.13.14)とSQLite(インメモリ)で、著者2,000人・書籍2,000件を用意して計測しました。各書き方を7回ずつ実行した中央値で、DEBUG=TrueとCaptureQueriesContextによるSQL記録を有効にしています。所要時間にはSQL記録の処理も含まれます。
| 書き方 | 発行クエリ数 | 所要時間(中央値) |
|---|---|---|
| 素のループ | 2,001 | 1,664ms |
select_related("author") |
1 | 55ms |
prefetch_related("author") |
2 | 112ms |
ネットワーク往復がほぼゼロのインメモリDBでも約1.7秒かかります。DBが別ホストにある本番では1本ごとに往復の遅延が上乗せされるので、差はさらに開きます。開発環境では気づかれず本番で顕在化しやすいのはこのためです。時間はマシンに依存するので、絶対値ではなく桁の差として読んでください。
N+1問題の検出方法:開発時はツール、本番は1リクエストの本数
開発環境:クエリ数を数える方法と検出ツール
DjangoではDEBUGが有効なとき、実行したSQLがconnection.queriesに溜まります。処理の前後で件数を比べれば、その区間の本数がわかります。
from django.db import connection, reset_queries
reset_queries()
names = [b.author.name for b in Book.objects.all()]
print(len(connection.queries)) # 書籍2,000件なら 2001
常用するなら、Djangoはdjango-debug-toolbar(2026年9月1日公開の8.0.0が最新)で、画面ごとの発行クエリ数と重複クエリを表示します。Railsはbullet(2026年10月24日公開の8.3.1)が定番で、N+1を見つけるとUSE eager loading detectedという警告と、追加すべきincludesを示します。Django向けのnplusoneは2018年5月の1.0.0で更新が止まっており、新規導入には向きません。
本番環境:APMによる反復クエリと発行本数の確認
N+1を構成する個々のSQLが設定した記録条件を満たさない場合、スロークエリログには残りません。しきい値を下げると記録量が増えるため、取得期間や対象を絞り、ログ量を確認しながら調整します。
本番では、1リクエストあたりのクエリ本数と、SQLの累積時間・リクエスト全体の応答時間を併せて確認します。APM(アプリケーション性能監視)を入れておけば、遅い画面のトレースを1本開いたときに、同じSQLのスパンが数百並ぶ形で見えます。同じ形のSQLが反復していたら、一覧の各要素に対応して追加取得が発生しているかを確認します。既存システムで発生源が絞り込めない場合は、データベース設計・移行支援で計測から改善までを引き受けています。
ORM別のN+1対策:事前ロードの書き方と発行されるSQL
| ORM | 事前ロード | 遅延ロードの禁止 |
|---|---|---|
| Django | select_related・prefetch_related |
fetch_mode(FETCH_RAISE)(6.1〜・一部) |
| Rails | includes・preload・eager_load |
strict_loading |
| Laravel | with・load |
preventLazyLoading |
| Hibernate・JPA | JOIN FETCH・@BatchSize |
標準機能なし |
| GraphQL | DataLoader |
標準機能なし |
select_relatedはJOINで関連先の列を同じSELECTに含めます。使えるのは外部キーと1対1だけで、多対多を渡すとFieldErrorになります。多対多をJOINすると親の行が子の数だけ複製されるので、Djangoはその形を書かせません。
多対多と逆参照にはprefetch_relatedを使います。関連ごとに別クエリを1本発行し、Python側で結び付けます。2本目のSQLは対象IDをIN句に並べる形で、書籍2,000件のprefetch_related("author")では長さ14,990文字・IN句に2,000個の著者IDが並びました。Django 6.1.2では("shop_author"."id") IN ((1), (2), ...)というタプル形式で出力されます。
実測ではprefetch_relatedのほうが1本多く、時間も約2倍でした。外部キーと1対1にはselect_relatedを選び、prefetch_relatedはそれ以外に回すのが基本です。遅延評価や集計を含むクエリ設計はDjango ORMのクエリ設計|遅延評価と集計の順序・生SQLへ落とす判断、APIで関連をネストする場合はDjango REST FrameworkのSerializerも確認してください。
Rails:includes・preload・eager_loadで変わるクエリの形
Active Record 8.1.4・Ruby 4.0.6・SQLite(インメモリ)で、著者200人・書籍600冊(1人3冊)を対象にa.books.map(&:title)を回したときの発行本数です。
| 書き方 | 発行クエリ数 | SQLの形 |
|---|---|---|
| 素のループ | 201 | 著者ごとに1本 |
preload(:books) |
2 | 著者1本+IN句1本 |
includes(:books) |
2 | 条件が無ければpreloadと同じ |
eager_load(:books) |
1 | LEFT OUTER JOIN |
includes(:books).where(books: {...}) |
1 | LEFT OUTER JOINに切り替わる |
includesは、関連を参照するハッシュ形式の条件やreferencesの指定などに応じてJOINへ切り替わります。文字列の条件では、明示的なreferencesが必要です。迷ったらincludesで構いませんが、関連先で絞り込みたいならeager_load、JOINで行が膨らむのを避けたいならpreloadと明示するほうが、発行されるSQLを読み違えません。関連の宣言ごとの発行SQLはRailsのAssociation(関連付け)とは?6種類の使い分けと発行SQLで実測しています。
Laravel:withとpreventLazyLoading、自動Eager Loading
LaravelのEloquentではwith()が事前ロードです。取得済みのコレクションに後から足すならload()を使います。
# N+1が起きる書き方
foreach (Book::all() as $book) {
echo $book->author->name;
}
# with() で2クエリに束ねる
$books = Book::with('author')->get();
# AppServiceProvider の boot() で、本番以外は遅延ロードを例外にする
Model::preventLazyLoading(! $this->app->isProduction());
Laravel 13.xの公式ドキュメントには、逆向きの仕組みとしてModel::automaticallyEagerLoadRelationships()も載っています。有効にすると、コレクション内の1件で関連にアクセスした時点で、同じコレクションの全件分をまとめて読み込みます(コレクション単位ならwithRelationshipAutoloading())。禁止して書き直させるか、自動で束ねさせるかは、既存コードの量で選ぶのが現実的です。モデルとリレーションの定義はLaravel Eloquentとは|モデル定義・リレーション・eager loadingの基礎にまとめています。
Hibernate・JPA:JOIN FETCH・@BatchSizeとOSIVの確認
Hibernate ORM(2026年11月時点の安定版は7.4.14.Final)では、JPQLのJOIN FETCHで関連を同時に取るか、@BatchSizeや@Fetch(FetchMode.SUBSELECT)で後続の取得をまとめます。単数の関連はJOIN FETCHで1本に、コレクションを複数たどる場面は行の重複が増えるのでバッチ方式に寄せるのが目安です。ORMの選定段階から比べるならJava ORM(O/Rマッパー)の種類と選び方を参照してください。
Spring Bootでは、spring.jpa.open-in-viewが既定で有効なことがN+1を隠します。リクエストの最後まで永続コンテキストが開いているため、ビューやJSON変換の段階で遅延ロードが例外にならずに成功してしまうからです。無効にすると取得漏れがその場で表面化します。切り替えの影響はspring.jpa.open-in-viewとは?OSIVの既定値trueと起動時警告の消し方で解説しています。
ORMの外で起きるN+1:GraphQLのリゾルバとAPIの連鎖呼び出し
GraphQLはフィールドごとにリゾルバが走るため、書籍20件の著者を返すだけで著者のリゾルバが20回呼ばれます。各書籍のリゾルバが著者を個別に取得する実装だと、要求された著者フィールドの解決に書籍件数分の問い合わせが発生します(仕組みはGraphQLとは?読み方・REST APIとの違いとクエリ・リゾルバの書き方)。標準の解決策はDataLoaderで、JavaScript実装のdataloader(2.2.3)は同じイベントループ内のload()呼び出しを溜めて1回のバッチ取得にまとめます。
// リクエストごとの context 生成時に呼び、ローダーをリクエスト間で共有しない
function createLoaders(fetchAuthorsByIds) {
return {
authorLoader: new DataLoader(async (ids) => {
const rows = await fetchAuthorsByIds(ids);
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id) ?? null);
}),
};
}
const resolvers = {
Book: {
author: (book, args, context) => context.authorLoader.load(book.authorId),
},
};
バッチ関数は、受け取ったIDと同じ順番で結果を返す必要があります。順番が崩れると、別の書籍に別の著者が紐づきます。DataLoaderはキャッシュも持つため、公式READMEのとおりリクエストごとに生成し、取得関数にはそのリクエストの認可条件を適用したものを渡します。
Prismaは「N+1が起きにくい」と紹介されることがありますが、範囲は限られます。Prisma公式ドキュメントによると、Prisma Clientのdataloaderが同じtickでまとめるのはfindUnique()だけで、findMany()はまとめません(Prisma Docs: Query optimization)。ループの中でfindMany()を呼べば普通にN+1になります。PrismaとTypeORMでincludeやrelationsが出すSQLはTypeORMとPrismaの違いと選び方|型安全・発行SQLを実測で比べています。
マイクロサービスでは同じ形がHTTPで現れます。一覧APIを1回呼び、各要素について詳細APIを1回ずつ呼ぶ実装です。詳細APIを直列に呼び、1往復に20ミリ秒かかるなら、100件の追加取得に合計2秒かかります。IDの配列を受け取るバッチ取得のエンドポイントを用意するか、一覧のレスポンスに必要な項目を含めて呼び出し回数を減らします。
事前ロードしても直らないN+1:実測で確かめた落とし穴
「prefetch_relatedやpreloadを付けたのに速くならない」場合は、次のどれかに当たっていることがほとんどです。直したつもりで終わらせず、必ず本数を数え直してください。
著者20人をprefetch_related("books")で取ったあと、ループでa.books.filter(published=True)と書くと、2本のはずが22本になりました。このfilter()は、プリフェッチしたall()とは異なるクエリを作るため、取得済みのキャッシュを使いません。絞り込みが決まっているなら、Prefetchオブジェクトで先に済ませます。
from django.db.models import Prefetch
# 22クエリ(N+1が復活)
[list(a.books.filter(published=True)) for a in Author.objects.prefetch_related("books")[:20]]
# 2クエリ(絞り込みを事前に済ませる)
[a.pub for a in Author.objects.prefetch_related(
Prefetch("books", queryset=Book.objects.filter(published=True), to_attr="pub"))[:20]]
外部キーの値だけが欲しいのにb.author.idと書くのも定番の失敗です。書籍2,000件でb.author.idは2,001クエリ・1,661ミリ秒、b.author_idは1クエリ・35ミリ秒でした。Django公式の高速化ガイドも、外部キーの値は手元のentry.blog_idを使うよう明記しています。
なお、select_related("author").only("title")のように関連先をonly()から外すと、Django 6.1.2ではFieldError: Field Book.author cannot be both deferred and traversed using select_related at the same time.で止まります。黙ってN+1になるわけではありません。評価済みのクエリセットに対するcount()・exists()・len()はいずれも追加クエリ0本で、1本走るのは未評価のまま呼んだときだけでした。
Rails:preload後のcount・where・pluck・sizeの違い
前章と同じ著者200人・書籍600冊で、preload(:books)のあとに呼ぶメソッドだけを変えました。
| preload後の呼び出し | 発行クエリ数 |
|---|---|
a.books.size |
2 |
a.books.pluck(:title) |
2 |
a.books.count |
202 |
a.books.where(published: true).to_a |
202 |
countは読み込み済みかどうかに関係なく毎回SELECT COUNT(*)を発行するので、件数表示に使うとN+1がそのまま戻ります。読み込み済みの件数はsizeで取ってください(sizeとcountの差はRails counter_cacheの使い方|sizeとcountの違いで詳しく扱っています)。whereもDjangoのfilter()と同じで新しいクエリになります。一方、pluckはRails 8.1.4では読み込み済みのレコードから値を取り、追加のクエリを出しませんでした。「preload後のpluckはN+1になる」という説明を見かけたら、手元の版で数え直す価値があります。
あえてN+1を残してよい条件
N+1は常に潰すべきとは限りません。事前ロードにもコストがあるからです。
- 1画面の件数に上限があり小さい:書籍20件の素のループは21クエリ・約11ミリ秒でした。ただし、この値はインメモリDBでの結果です。少件数でも本番相当の通信遅延と負荷で比較し、応答時間の目標を満たす場合に限って現状維持を検討します。
- 親が数万件に達する:IN句が数万個に膨らみ、SQLの構築自体が重くなります。事前ロードより先にページネーション設計やバッチ分割で件数に上限を設けるべき場面です。
- 関連をキャッシュから返している:事前ロードしたデータが使われず、DBアクセスだけが増えます。
判断の順序は、クエリ数と所要時間を測る、親の最大件数と同時アクセス時の負荷を確かめる、応答時間やDB負荷の目標を満たさない取得処理から改善する、です。本数を減らしたあとで残った1本が遅いなら、EXPLAIN ANALYZEによる実行計画の診断に進みます。
再発を止める:遅延ロードの禁止とクエリ数のテスト
Railsのstrict_loadingと版ごとの指定方法
Rails 6.1で入ったstrict_loadingを有効にすると、遅延ロードが起きた時点で例外になります。8.1.4でBook.strict_loadingから著者を参照すると、ActiveRecord::StrictLoadingViolationError: `Book` is marked for strict_loading. The Author association named `:author` cannot be lazily loaded.が出ました。
| 書き方 | 粒度 | 導入バージョン |
|---|---|---|
strict_loading_by_default |
アプリ全体 | 6.1 |
strict_loading: true |
関連の宣言 | 6.1 |
.strict_loading |
リレーション | 6.1 |
strict_loading!(mode: :n_plus_one_only) |
レコード | 7.0 |
strict_loading_mode |
アプリ全体 | 8.0 |
注意点が1つあります。strict_loadingを付けたリレーションでも、a.books.sizeは例外になりませんでした。関連を読み込まずに著者ごとのSELECT COUNT(*)を発行するため、遅延ロードとして扱われないのです。件数表示のN+1はstrict_loadingでは止まらないので、次のテストで押さえます。Active Recordのパターン自体の落とし穴はActive Record(アクティブレコード)とは?仕組みと発行SQL・N+1・検証の落とし穴にあります。
DjangoのassertNumQueriesと6.1のfetch_mode
Djangoで最も確実なのは、テストにクエリ数を書き込むassertNumQueriesです。後日の変更でN+1が混入した時点でCIが落ちます。
from django.test import TestCase
class BookListTests(TestCase):
def test_query_count(self):
with self.assertNumQueries(2):
self.client.get("/books/")
Django 6.1(現行は6.1.2、メインストリームサポートは2027年4月まで)ではQuerySet.fetch_mode()が追加されました。FETCH_RAISEを指定すると、外部キーの遅延ロードでFieldFetchBlocked: Fetching of Book.author blocked.が出ます。ただし公式ドキュメントが挙げる適用対象は外部キー、1対1とその逆アクセサ、defer()・only()で遅延させたフィールド、generic relationsだけで、関連マネージャのクエリには効かないと明記されています。実際に多対多のb.tags.all()は書籍3件で例外なく4本流れました。同時に追加されたFETCH_PEERSは兄弟インスタンスの関連をまとめて取る動作で、書籍20件の素のループが21本から2本になりました。多対多まで止める手段は無いため、当面はassertNumQueriesと組み合わせます。
よくある質問
N+1問題とは何ですか?
一覧を取る1回のクエリに加え、各行の関連データを取るN回のクエリが発行され、合計N+1回になる性能問題です。Django 6.1.2で書籍2,000件を回すと2,001クエリが流れ、事前ロードした1クエリの場合より約30倍遅くなりました。ORMの遅延ロードで起きやすいものの、ループの中で1件ずつ問い合わせる実装なら何でも同じことが起きます。
N+1問題の読み方は?
「エヌプラスワンもんだい」と読みます。N+1クエリ問題、英語ではN+1 query problemやN+1 selects problemとも呼ばれ、1+N問題と書く資料もありますが同じものです。
IT用語で「N+1」とは何ですか?
データベースやORMの文脈ならN+1問題(クエリが件数分増える性能問題)を指します。サーバーや電源の文脈では、必要台数Nに予備を1台足すN+1冗長構成の意味で使われます。両者は無関係なので、前後の話題で見分けてください。
SQLを直接書けばN+1問題は起きませんか?
起きます。アプリケーション側のループの中で主キー1件指定のSELECTを実行すれば、ORMと同じ本数が流れます。原因はORMではなく1件ずつ問い合わせる実装です。手書きの場合はJOINかWHERE id IN (...)で1〜2本にまとめます。
Prismaを使えばN+1問題は起きませんか?
PrismaでもN+1問題は起きます。自動バッチ処理の対象は限定されています。Prisma公式ドキュメントによると、Prisma Clientのdataloaderが自動でまとめるのは同じtickのfindUnique()で、findMany()はまとめません。ループ内のfindMany()はN+1になるので、includeで関連を同時に取ります。