N+1問題とは、一覧を1本のSQLで取ったあと、各行の関連データを1件ずつ取りにいくため、SQLが「1+N本」発行される性能問題です。 投稿100件の一覧で著者名を出すと、投稿の取得1本+著者の取得100本で、合計101本になります。直し方は「関連をまとめて取る」こと(JOIN か IN句)で、本数は件数に関係なく1〜3本になります。

この記事では、PostgreSQL 16 に同じデータを用意し、N+1 が起きる書き方と直した書き方で、発行されたSQLの本数と時間を、件数10・100・1000で実測しました。結果の要約は次のとおりです(Python の SQLAlchemy 2.1、投稿に著者とコメントを付けて表示する処理)。

件数N N+1:SQLの本数 N+1:時間(往復0.02ms / 2.9ms) 直した後:本数 直した後:時間(同左)
10 21本 12.3ms / 89.3ms 2本 2.8ms / 12.5ms
100 201本 97.5ms / 725.2ms 2本 8.2ms / 18.2ms
1000 2,001本 1,039.5ms / 7,521.3ms 3本 71.7ms / 100.8ms

「往復」はアプリとDBの間を1回やり取りする時間です。同じマシン内(約0.02ms)と、遅延を足して別サーバー程度にした接続(約2.9ms)の両方で測りました。N+1 の時間は件数と往復時間の掛け算で伸び、1000件・往復2.9msでは7.5秒かかりました。 直した書き方は同じ条件で0.1秒でした。

この記事と関連記事の分担

この記事は N+1問題の仕組み・見つけ方・各ORMの直し方をまとめた入口です。フレームワークごとの詳しい手順は次の記事に分けています。

知りたいこと 読む記事
Spring Data JPA での4手法のコード全文 JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文
Laravel の with・load の使い方 Laravel Eloquentの使い方|リレーション・N+1対策
SQLAlchemy のリレーション定義 SQLAlchemy 2.0の使い方|CRUD・リレーションを動くコードで解説
遅いSQLの探し方(pg_stat_statements) pg_stat_statementsで遅いSQLを探す|設定と分析クエリ集

確認環境:PostgreSQL 16.15(Ubuntu 24.04、apt のパッケージ)、Python 3.11 + SQLAlchemy 2.1.4 + psycopg 3.3.6、Django 5.2.18 + django-debug-toolbar 8.0.0、Node.js 22.22.2 + Drizzle ORM 0.45.4 + node-postgres 8.23.1 + DataLoader 2.2.3、Java 25(OpenJDK 25)+ PostgreSQL JDBC 42.7.14 で、この記事の計測とログをすべて実行しました。時間は2 vCPU の Linux VM で7回測った中央値です。往復2.9msの条件は、DBとの間に遅延を足す自作の TCP プロキシを挟んで作りました。Hibernate(7.4.13)はソースコードと公式ドキュメント、Rails 8.1・Laravel 13.x・MyBatis・Prisma ORM 7 は公式ドキュメントで確かめました(確認日 2026年10月16日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「N+1問題とは?原因・見つけ方・解決方法を実測比較【ORM別】」

N+1問題とは:SQLが「1+N本」になる仕組み

ブログの投稿(posts)と著者(users)、コメント(comments)の3つのテーブルで、投稿一覧に著者名とコメント数を表示する例です。

posts = session.scalars(select(Post).order_by(Post.id).limit(3)).all()  # 1本
for p in posts:
    print(p.title, p.author.name)  # ここで著者を1件ずつ取得している

このコードを SQLAlchemy で実行し、発行されたSQLをログ(echo=True)で出すと次のようになりました。

SELECT posts.id, posts.user_id, posts.title
FROM posts ORDER BY posts.id
 LIMIT %(param_1)s::INTEGER
[generated in 0.00405s] {'param_1': 3}
SELECT users.id, users.name
FROM users
WHERE users.id = %(pk_1)s::INTEGER
[generated in 0.00018s] {'pk_1': 1}
post1 user1
SELECT users.id, users.name
FROM users
WHERE users.id = %(pk_1)s::INTEGER
[cached since 0.003273s ago] {'pk_1': 2}
post2 user2
SELECT users.id, users.name
FROM users
WHERE users.id = %(pk_1)s::INTEGER
[cached since 0.00455s ago] {'pk_1': 3}
post3 user3

コードにはループと p.author という属性の参照しか書いていないのに、同じ形のSQLが、IDだけ変えて3回発行されています。ORM(データベースの行をオブジェクトとして扱うライブラリ)が、関連データに初めて触れた時点で自動的にSQLを発行する「遅延読み込み(Lazy Loading)」をしているためです。

用語 意味
1 一覧を取るSQL(最初の1本)
N 一覧の行ごとに関連を取るSQL(行数と同じ本数)
遅延読み込み(Lazy Loading) 関連に触れたときに初めてSQLを発行する読み込み方。多くのORMの既定
先読み(Eager Loading) 一覧を取るときに、関連もまとめて取る読み込み方。N+1の直し方

関連が「著者」と「コメント」の2つあると、本数は 1+N+N(N=100 なら201本)に増えます。冒頭の表の201本・2,001本はこのケースです。

N+1問題で遅くなる理由:件数10・100・1000で実測

計測の条件

項目 内容
データ users 1,000行、posts 1,000行(投稿ごとに著者が違う)、comments 5,000行(1投稿5件)、post_tags 3,000行
処理 投稿を id 順に N 件取り、著者名とコメント数を読む(JDBC は著者名だけ)
件数 N=10・100・1000
回数 1回空打ちしてから7回測り、中央値
接続 直接(1往復 約0.02〜0.08ms)と、遅延を足した接続(1往復 約2.9ms)

往復時間は、SELECT 1 を500回実行した平均です(直接 0.022〜0.079ms、遅延あり 2.862〜2.895ms)。

直接接続(往復 約0.02〜0.08ms)の結果

ライブラリ・書き方 N=10 N=100 N=1000
SQLAlchemy:遅延読み込み(N+1) 21本・12.3ms 201本・97.5ms 2,001本・1,039.5ms
SQLAlchemy:joinedload+selectinload 2本・2.8ms 2本・8.2ms 3本・71.7ms
Django:遅延読み込み(N+1) 21本・17.8ms 201本・108.5ms 2,001本・1,357.8ms
Django:select_related+prefetch_related 2本・3.3ms 2本・12.8ms 2本・113.4ms
Drizzle:ループ内で select(N+1) 21本・14.4ms 201本・130.5ms 2,001本・448.8ms
Drizzle:IN句で一括取得 3本・1.4ms 3本・4.2ms 3本・19.6ms
Drizzle:リレーショナルクエリ(with) 1本・1.3ms 1本・3.2ms 1本・16.8ms
JDBC:ループ内で SELECT(N+1) 11本・1.0ms 101本・4.8ms 1,001本・21.8ms
JDBC:= ANY(配列) で一括取得 2本・0.2ms 2本・0.5ms 2本・2.3ms

遅延を足した接続(往復 約2.9ms)の結果

ライブラリ・書き方 N=10 N=100 N=1000
SQLAlchemy:遅延読み込み(N+1) 21本・89.3ms 201本・725.2ms 2,001本・7,521.3ms
SQLAlchemy:joinedload+selectinload 2本・12.5ms 2本・18.2ms 3本・100.8ms
Django:遅延読み込み(N+1) 21本・72.2ms 201本・689.5ms 2,001本・7,002.1ms
Django:select_related+prefetch_related 2本・9.2ms 2本・15.1ms 2本・135.6ms
Drizzle:ループ内で select(N+1) 21本・74.4ms 201本・658.6ms 2,001本・6,418.6ms
Drizzle:IN句で一括取得 3本・11.5ms 3本・15.7ms 3本・48.4ms
Drizzle:リレーショナルクエリ(with) 1本・4.7ms 1本・10.2ms 1本・24.8ms
JDBC:ループ内で SELECT(N+1) 11本・33.3ms 101本・285.1ms 1,001本・2,924.8ms
JDBC:= ANY(配列) で一括取得 2本・5.9ms 2本・6.3ms 2本・8.4ms

結果から分かることは次の3つです。

  • N+1 の時間は「本数 × 1往復の時間」でほぼ決まります。 JDBC の 1,001本・2,924.8ms は、1,001 × 2.9ms ≒ 2.9秒とほぼ一致します。
  • 同じマシン内の開発環境では目立ちません。 JDBC の N+1 は1000件でも21.8msでした。DBが別サーバーになる本番で、急に遅くなる理由がここにあります。
  • ORM は1本ごとの処理(オブジェクトへの変換など)も重く、 直接接続でも1000件で1秒前後かかりました。

DBの中では速い:pg_stat_statements で見た内訳

SQLAlchemy の N+1(N=100、201本)を1回実行した後、PostgreSQL の pg_stat_statements(SQLごとの実行回数と時間を集計する拡張)を見ると次のとおりでした。

 calls | rows | total_ms | mean_ms |                            query
-------+------+----------+---------+--------------------------------------------------------------
   100 |  100 |     1.01 |   0.010 | SELECT users.id, users.name FROM users WHERE users.id = $1::
   100 |  500 |     2.30 |   0.023 | SELECT comments.id, comments.post_id, comments.body FROM com

DBの中での実行時間は、200本を合わせても約3.3msでした。一方、アプリ側で測った時間は約100msです。差のほとんどは往復とORMの処理です。1本1本は速いため、一定時間を超えたSQLだけを記録するスロークエリログには、N+1 はほとんど出ません。 見つけるには「本数(calls)」を見ます。

N+1問題の見つけ方

方法 見るもの 向いている場面
SQLログ 同じ形のSQLが、IDだけ変えて並んでいないか 開発中に1画面ずつ確かめる
pg_stat_statements calls が極端に多いSQL 本番・検証環境のDB全体から探す
Django Debug Toolbar 「〇 similar queries」の表示 Django の画面ごとに確かめる
Hibernate の統計 セッションごとの JDBC 文の本数 Spring Boot・JPA
遅延読み込みを例外にする 例外が出た箇所 開発中・テストで入り込みを止める
テストで本数を固定する 本数が変わるとテストが失敗する 後から入り込むのを防ぐ

SQLログを出す設定

ORM・フレームワーク 設定
SQLAlchemy create_engine(url, echo=True)、またはロガー sqlalchemy.engine を INFO
Django ロガー django.db.backends を DEBUG(DEBUG = True のとき)、または connection.queries
Spring Boot(Hibernate) spring.jpa.show-sql=true、またはロガー org.hibernate.SQL を DEBUG
Rails development 環境では log/development.log に出る
Laravel DB::listen() でクエリを受け取る、または Laravel Debugbar
Drizzle drizzle(pool, { logger: true })
PostgreSQL 側 log_min_duration_statement = 0(全SQLを記録。本数が多いので一時的に)

pg_stat_statements で本数の多いSQLを探す

pg_stat_statements は、postgresql.conf の shared_preload_libraries に追加して再起動し、DBで CREATE EXTENSION pg_stat_statements; を実行すると使えます。上の出力は次のSQLで出しました。

SELECT calls,
       rows,
       round(total_exec_time::numeric, 2) AS total_ms,
       round(mean_exec_time::numeric, 3)  AS mean_ms,
       left(regexp_replace(query, '\s+', ' ', 'g'), 60) AS query
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY calls DESC
LIMIT 5;

mean_ms が小さいのに calls だけが多く、WHERE id = $1 の形をしたSQLが N+1 の候補です。集計を0に戻すには SELECT pg_stat_statements_reset(); を実行します。設定と分析クエリは、関連記事「pg_stat_statementsで遅いSQLを探す|設定と分析クエリ集」で扱っています。

Django Debug Toolbar の表示

投稿10件の一覧ページ(著者名を表示)を django-debug-toolbar 8.0.0 を入れて開くと、SQL パネルに次のように出ました。

SQL 11 queries in 6.28ms
default 6.28 ms (11 queries including 10 similar )
...
SELECT ••• FROM "users" WHERE "users"."id" = 1 LIMIT 21
10 similar queries.

「10 similar queries」が N+1 の印です。同じ形のSQLが10本あることを示しています。

Hibernate の統計(Spring Boot・JPA)

Hibernate 7.4.13 のソースでは、ロガー org.hibernate.session.metrics を DEBUG にすると、セッション(多くの場合は1リクエスト)が終わるたびに次の集計を記録します(%s に数値が入ります)。

Logging session metrics:
    %s ns acquiring %s JDBC connections
    %s ns releasing %s JDBC connections
    %s ns preparing %s JDBC statements
    %s ns executing %s JDBC statements
    ...
# application.properties(Spring Boot)
# セッションごとの集計(JDBC 文の本数など)をログに出す
logging.level.org.hibernate.session.metrics=DEBUG
# SessionFactory 全体の統計(Statistics)を集める
spring.jpa.properties.hibernate.generate_statistics=true

1画面で「executing 101 JDBC statements」のように件数に比例した本数が出たら、N+1 を疑います。generate_statistics で集めた統計は Statistics#getPrepareStatementCount() などで読めるため、テストで本数を確かめるときにも使えます。

遅延読み込みを例外にする

開発環境で「遅延読み込みが起きたら例外にする」設定を入れると、N+1 をレビューより前に見つけられます。

ORM 設定 起きる例外
SQLAlchemy .options(raiseload("*")) または relationship(lazy="raise") InvalidRequestError
Rails Book.strict_loading など ActiveRecord::StrictLoadingViolationError
Laravel Model::preventLazyLoading(! $this->app->isProduction()); Illuminate\Database\LazyLoadingViolationException

SQLAlchemy で raiseload("*") を付けて取得し、著者に触れたときの例外は次のとおりでした。

InvalidRequestError : 'Post.author' is not available due to lazy='raise'

Laravel は Model::handleLazyLoadingViolationUsing(...) で、例外の代わりにログへ記録することもできます(Laravel 13.x のドキュメント)。

テストでSQLの本数を固定する(Django)

from django.test import TestCase

class PostListTest(TestCase):
    def test_list(self):
        with self.assertNumQueries(2):
            for p in Post.objects.order_by("id")[:3]:
                p.user.name

N+1 のまま実行すると、次のように失敗し、発行されたSQLの一覧も表示されます。

AssertionError: 4 != 2 : 4 queries executed, 2 expected
Captured queries were:
1. SELECT "posts"."id", "posts"."user_id", "posts"."title" FROM "posts" ORDER BY "posts"."id" ASC LIMIT 3
2. SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."id" = 1 LIMIT 21
3. SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."id" = 2 LIMIT 21
4. SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."id" = 3 LIMIT 21

一覧画面のテストで本数を固定しておくと、後から誰かが N+1 を持ち込んだときにテストが失敗します。

N+1問題の解決方法:JOIN・IN句・バッチ・DataLoaderの比較

直し方は「関連をまとめて取る」ことで、まとめ方は大きく次の5つです。

方法 SQLの本数 向いている関連 ページングとの相性 注意点
JOIN で一緒に取る(JOIN FETCH・joinedload・select_related・eager_load) 1本 多対1・1対1 多対1なら問題なし。1対多だと LIMIT がずれる 1対多を JOIN すると行が増える
EntityGraph(JPA) 1本(JOIN) 多対1・1対1 JOIN FETCH と同じ メソッドに注釈を付けるだけで使える
IN句で一括取得(selectinload・prefetch_related・preload・with) 関連1つにつき1本 1対多・多対多 親を先にページングできる IN に入る件数の上限(後述)
バッチ取得(@BatchSize・default_batch_fetch_size) 1+N÷バッチサイズ 1対多・多対1 親を先にページングできる コードを変えずに効くが、本数は0にならない
DataLoader(GraphQL など) まとめて1本 項目ごとにデータを取る作り 影響なし リクエストごとに作り直す

目安は「多対1・1対1は JOIN、1対多・多対多は IN句」です。上の実測でも、著者(多対1)は JOIN、コメント(1対多)は IN句にした組み合わせが、どの件数でも安定していました。

フレームワーク別の解決方法

SQLAlchemy 2.1(Python)

計測に使ったコードを、そのまま動く形で載せます。テーブルは冒頭の posts・users・comments です。

import time
from sqlalchemy import create_engine, event, select, ForeignKey
from sqlalchemy.orm import (DeclarativeBase, Mapped, mapped_column, relationship,
                            Session, joinedload, selectinload)

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    title: Mapped[str]
    author: Mapped[User] = relationship()
    comments: Mapped[list["Comment"]] = relationship(order_by="Comment.id")

class Comment(Base):
    __tablename__ = "comments"
    id: Mapped[int] = mapped_column(primary_key=True)
    post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"))
    body: Mapped[str]

engine = create_engine("postgresql+psycopg://user:pass@127.0.0.1:5432/app")

count = 0
@event.listens_for(engine, "before_cursor_execute")
def count_sql(conn, cursor, statement, params, context, executemany):
    global count
    count += 1  # 発行されたSQLの本数を数える

def show(n, options):
    with Session(engine) as s:
        stmt = select(Post).order_by(Post.id).limit(n).options(*options)
        for p in s.scalars(stmt).unique().all():
            _ = (p.author.name, len(p.comments))

for label, options in [
    ("N+1", []),
    ("fixed", [joinedload(Post.author), selectinload(Post.comments)]),
]:
    count = 0
    t = time.perf_counter()
    show(100, options)
    print(label, count, "queries", round((time.perf_counter() - t) * 1000, 1), "ms")
ローダー 取り方 向いている関連
joinedload LEFT OUTER JOIN(1本) 多対1・1対1
selectinload WHERE ... IN (...)(関連1つにつき1本) 1対多・多対多
subqueryload 元のクエリをサブクエリにして取得 旧来の方式(selectinload が推奨)
raiseload 取らずに、触れたら例外 N+1 の検出

直した書き方で発行されたSQLは次の2本でした(N=3)。

SELECT posts.id, posts.user_id, posts.title, users_1.id AS id_1, users_1.name
FROM posts LEFT OUTER JOIN users AS users_1 ON users_1.id = posts.user_id ORDER BY posts.id
 LIMIT %(param_1)s::INTEGER
SELECT comments.post_id, comments.id, comments.body
FROM comments
WHERE comments.post_id IN (%(primary_keys_1)s::INTEGER, %(primary_keys_2)s::INTEGER, %(primary_keys_3)s::INTEGER) ORDER BY comments.id

N=1000 で本数が3本・5本に増えたのは、selectinload が IN に入れるキーを500件ずつに分けるためです(SQLAlchemy 2.1.4 のソースで _chunksize = 500)。

Django 5.2

# N+1:201本(N=100)
for p in Post.objects.order_by("id")[:100]:
    p.user.name, len(p.comments.all())

# 直した書き方:2本
qs = Post.objects.order_by("id").select_related("user").prefetch_related("comments")
for p in qs[:100]:
    p.user.name, len(p.comments.all())

直した書き方で発行されたSQLです(N=3)。

SELECT "posts"."id", "posts"."user_id", "posts"."title", "users"."id", "users"."name" FROM "posts" INNER JOIN "users" ON ("posts"."user_id" = "users"."id") ORDER BY "posts"."id" ASC LIMIT 3
SELECT "comments"."id", "comments"."post_id", "comments"."body" FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3)
メソッド 取り方 使える関連
select_related JOIN ForeignKey・OneToOneField(1件に対して1件)
prefetch_related IN句 逆参照・ManyToManyField(1件に対して複数件)
Prefetch("comments", queryset=...) IN句(絞り込み・並べ替え付き) 関連側に条件を付けたいとき

ループの中で p.comments.filter(...) のように新しい条件を付けると、先読みした結果は使われず、またSQLが発行されます。 条件は Prefetch の queryset に書きます。

Drizzle ORM・Prisma(Node.js / TypeScript)

Drizzle では、relations() を定義して db.query.<テーブル>.findMany({ with: ... }) を使うと、関連を1本のSQLで取れます。

import { drizzle } from "drizzle-orm/node-postgres";
import { pgTable, bigint, text } from "drizzle-orm/pg-core";
import { relations, inArray, asc } from "drizzle-orm";
import pg from "pg";

const users = pgTable("users", { id: bigint("id", { mode: "number" }).primaryKey(), name: text("name").notNull() });
const posts = pgTable("posts", { id: bigint("id", { mode: "number" }).primaryKey(), userId: bigint("user_id", { mode: "number" }).notNull(), title: text("title").notNull() });
const comments = pgTable("comments", { id: bigint("id", { mode: "number" }).primaryKey(), postId: bigint("post_id", { mode: "number" }).notNull(), body: text("body").notNull() });
const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, { fields: [posts.userId], references: [users.id] }),
  comments: many(comments),
}));
const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, { fields: [comments.postId], references: [posts.id] }),
}));

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool, { schema: { users, posts, comments, postsRelations, commentsRelations }, logger: true });

// 方法1:リレーショナルクエリ(1本)
const list = await db.query.posts.findMany({
  with: { author: true, comments: true },
  orderBy: (p, { asc }) => [asc(p.id)],
  limit: 100,
});

// 方法2:IN句で一括取得して、アプリ側で組み合わせる(3本)
const ps = await db.select().from(posts).orderBy(asc(posts.id)).limit(100);
const us = await db.select().from(users).where(inArray(users.id, [...new Set(ps.map((p) => p.userId))]));
const cs = await db.select().from(comments).where(inArray(comments.postId, ps.map((p) => p.id)));
const userById = new Map(us.map((u) => [u.id, u]));
const commentsByPost = Map.groupBy(cs, (c) => c.postId);

方法1で発行されたSQLは、関連ごとに LEFT JOIN LATERAL と JSON の集計を使う1本でした。

select "posts"."id", "posts"."user_id", "posts"."title", "posts_author"."data" as "author", "posts_comments"."data" as "comments" from "posts" "posts" left join lateral (select json_build_array("posts_author"."id", "posts_author"."name") as "data" from (select * from "users" "posts_author" where "posts_author"."id" = "posts"."user_id" limit $1) "posts_author") "posts_author" on true left join lateral (select coalesce(json_agg(json_build_array("posts_comments"."id", "posts_comments"."post_id", "posts_comments"."body")), '[]'::json) as "data" from "comments" "posts_comments" where "posts_comments"."post_id" = "posts"."id") "posts_comments" on true order by "posts"."id" asc limit $2

コメントを JSON にまとめてから結合するため、1対多でも投稿の行は増えず、limit も投稿の件数どおりに効きます。スキーマの書き方は、関連記事「Drizzle ORMの使い方|スキーマ定義とマイグレーション」で扱っています。

Prisma は、findMany({ include: { posts: true } }) のように include で関連をまとめて取ります。Prisma ORM 7 のドキュメントでは、relationLoadStrategy(Preview。relationJoins を有効にして使う)で、"join"(PostgreSQL では LATERAL JOIN)と "query"(テーブルごとに1本ずつ送り、アプリ側で組み合わせる)を選べると説明されています。

生のSQLでも起きる:JDBC(Java 25)

ORM を使わなくても、ループの中でSQLを実行すれば N+1 になります。PostgreSQL では、IN句の代わりに = ANY(?) に配列を1つ渡すと、件数が変わっても同じSQL文で済みます。

import java.sql.*;
import java.util.*;

public class JdbcNPlusOne {
    record Post(long id, long userId, String title) {}

    // N+1:1件ずつ著者を取得(N本)
    static Map<Long, String> nPlusOne(Connection c, List<Post> posts) throws SQLException {
        Map<Long, String> names = new HashMap<>();
        try (PreparedStatement ps = c.prepareStatement("SELECT name FROM users WHERE id = ?")) {
            for (Post p : posts) {
                ps.setLong(1, p.userId());
                try (ResultSet rs = ps.executeQuery()) {
                    rs.next();
                    names.put(p.id(), rs.getString(1));
                }
            }
        }
        return names;
    }

    // 一括取得:= ANY(配列) で1本
    static Map<Long, String> bulk(Connection c, List<Post> posts) throws SQLException {
        Long[] ids = posts.stream().map(Post::userId).distinct().toArray(Long[]::new);
        Map<Long, String> byUser = new HashMap<>();
        try (PreparedStatement ps = c.prepareStatement("SELECT id, name FROM users WHERE id = ANY(?)")) {
            ps.setArray(1, c.createArrayOf("bigint", ids));
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) byUser.put(rs.getLong(1), rs.getString(2));
            }
        }
        Map<Long, String> names = new HashMap<>();
        for (Post p : posts) names.put(p.id(), byUser.get(p.userId()));
        return names;
    }

    public static void main(String[] args) throws Exception {
        try (Connection c = DriverManager.getConnection(
                "jdbc:postgresql://127.0.0.1:5432/app", "user", "pass")) {
            List<Post> posts = new ArrayList<>();
            try (PreparedStatement ps = c.prepareStatement(
                    "SELECT id, user_id, title FROM posts ORDER BY id LIMIT 100");
                 ResultSet rs = ps.executeQuery()) {
                while (rs.next()) posts.add(new Post(rs.getLong(1), rs.getLong(2), rs.getString(3)));
            }
            System.out.println(nPlusOne(c, posts).size() + " / " + bulk(c, posts).size());
        }
    }
}

実測では、1000件・往復2.9msで N+1 が2,924.8ms、= ANY(?) が8.4msでした。

JPA(Hibernate)・Spring Data JPA

public interface PostRepository extends JpaRepository<Post, Long> {

    // 1. JOIN FETCH(多対1をJOINで一緒に取る)
    @Query("SELECT p FROM Post p JOIN FETCH p.author")
    List<Post> findAllWithAuthor();

    // 2. @EntityGraph(注釈で関連を指定。中身は JOIN)
    @EntityGraph(attributePaths = {"author"})
    List<Post> findAllByOrderByIdAsc();
}
// 3. @BatchSize(このコレクションを、最大100件分ずつまとめて取る)
@OneToMany(mappedBy = "post")
@BatchSize(size = 100)
private List<Comment> comments;
# 4. すべての関連に既定のバッチサイズを設定(Spring Boot)
spring.jpa.properties.hibernate.default_batch_fetch_size=100
手法 取り方 本数(投稿1000件のコメント) 向いている場面
JOIN FETCH JOIN(1本) 1本(行は5,000行) 多対1、件数が少ない1対多
@EntityGraph JOIN(1本) 1本 メソッド名のクエリにそのまま付けたい
@BatchSize / default_batch_fetch_size ID をまとめて取得 1+1000÷100=11本 1対多、ページングと併用
@Fetch(FetchMode.SUBSELECT) 元のクエリをサブクエリにして取得 2本 1対多をまとめて1回で取りたい

Hibernate 7.4.13 のソースでは、PostgreSQL のように配列型を使えるDBで、バッチ取得の ID を配列のパラメーター1つにまとめて送る実装(EntityBatchLoaderArrayParam・CollectionBatchLoaderArrayParam)が選ばれます。

FetchType.EAGER にしても N+1 は直りません。 JPQL で一覧を取った後、EAGER の関連が1件ずつ追加のSQLで取られることがあります。関連は LAZY にしておき、必要な画面で JOIN FETCH・@EntityGraph・バッチ取得を使います。4手法のコード全文と Spring Boot での実行結果は、関連記事「JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文」で扱っています。

MyBatis

MyBatis の公式ドキュメントは、<association select="...">(ネストした select)を一覧に使うと 「N+1 Selects Problem」 になると説明しています。直し方は、JOIN した結果を resultMap で組み立てる「ネストした結果(Nested Results)」です。

<select id="selectPosts" resultMap="postResult">
  SELECT p.id AS post_id, p.title,
         u.id AS user_id, u.name AS user_name
  FROM posts p
  JOIN users u ON u.id = p.user_id
  ORDER BY p.id
</select>

<resultMap id="postResult" type="Post">
  <id property="id" column="post_id"/>
  <result property="title" column="title"/>
  <association property="author" javaType="User">
    <id property="id" column="user_id"/>
    <result property="name" column="user_name"/>
  </association>
</resultMap>

ドキュメントは、<id> で行を一意に見分ける列を必ず指定するよう案内しています。fetchType="lazy" にしても、一覧を取ってすぐループで関連に触れれば、遅延読み込みがすべて実行されます。

Rails(Active Record)

Book.includes(:author).limit(10)    # 状況に応じて IN句 か JOIN を選ぶ
Book.preload(:author).limit(10)     # 必ず IN句(関連ごとに1本)
Book.eager_load(:author).limit(10)  # 必ず LEFT OUTER JOIN(1本)
メソッド 取り方 使いどころ
includes 通常は IN句。関連先の条件を使うと JOIN 迷ったとき
preload IN句 1対多・多対多。関連先の条件は付けられない
eager_load LEFT OUTER JOIN 関連先の列で絞り込み・並べ替えをしたい

Rails 8.1 のガイドでは、includes(:author).limit(10) は SELECT books.* FROM books LIMIT 10 と SELECT authors.* FROM authors WHERE authors.id IN (...) の2本になると説明されています。

Laravel(Eloquent)

$books = Book::with('author')->get();               // 2本(books と authors を IN句で)
$books = Book::with(['author', 'publisher'])->get(); // 複数の関連
$books = Book::with('author.contacts')->get();       // 入れ子の関連
$books->load('author');                               // 取得後にまとめて読む

Laravel 13.x のドキュメントには、触れた関連を自動でまとめて読み込む Model::automaticallyEagerLoadRelationships() も載っています。使い方は、関連記事「Laravel Eloquentの使い方|リレーション・N+1対策」で扱っています。

GraphQL(DataLoader)

GraphQL はフィールドごとにリゾルバー(値を返す関数)が動くため、Post.author をそのまま書くと投稿の数だけSQLが出ます。DataLoader は、同じタイミングで頼まれたキーを集めて、1回の関数呼び出しにまとめます。

import DataLoader from "dataloader";

// リクエストごとに作る(キャッシュを他のリクエストと共有しない)
function createLoaders(pool) {
  return {
    user: new DataLoader(async (ids) => {
      const { rows } = await pool.query("SELECT id, name FROM users WHERE id = ANY($1)", [ids]);
      const byId = new Map(rows.map((r) => [Number(r.id), r]));
      return ids.map((id) => byId.get(id) ?? null); // 渡されたキーと同じ順番・同じ件数で返す
    }),
  };
}

// リゾルバー
const resolvers = {
  Post: { author: (post, _args, ctx) => ctx.loaders.user.load(Number(post.user_id)) },
};

投稿100件の著者を解決した結果です(DataLoader 2.2.3)。

リゾルバーそのまま: 100 queries
DataLoader: 1 query -> SELECT id, name FROM users WHERE id = ANY($1) / 100 authors

バッチ関数は、受け取ったキーと同じ順番・同じ件数の配列を返します。 DB の結果は順番が保証されないため、Map で並べ直しています。DataLoader はキャッシュを持つため、リクエストごとに作り直すのが原則です。GraphQL と REST のリクエスト数の比較は、関連記事「GraphQLとRESTの違い|同じ画面を両方で作ってリクエスト数を実測」で扱っています。

直しすぎに注意:デカルト積・ページング・IN句の上限

1対多を2つ JOIN すると、行が掛け算で増える(デカルト積)

1投稿にコメント5件・タグ3件があるとき、両方を JOIN すると、1投稿あたり 5×3=15行になります。これをデカルト積(すべての組み合わせ)と呼びます。行数を数えた結果です。

投稿の件数 コメントだけ JOIN コメントとタグを JOIN
10 50行 150行
100 500行 1,500行
1000 5,000行 15,000行

SQLAlchemy で、全部 joinedload(1本)にした場合と、著者だけ JOIN してコメント・タグを selectinload にした場合の時間です。

件数N 全部 JOIN(1本)直接 / 遅延あり JOIN+IN句(3〜5本)直接 / 遅延あり
10 5.0ms / 11.5ms 4.8ms / 18.5ms
100 24.6ms / 24.8ms 13.4ms / 24.4ms
1000 268.1ms / 189.5ms 102.0ms / 133.8ms

件数が少なく往復が遅いときは、1本にまとめた JOIN が速いこともあります。件数が増えると、増えた行の転送と重複の取り除きで JOIN の方が遅くなりました(1000件で約1.4〜2.6倍)。1対多・多対多が2つ以上あるときは、IN句(selectinload・prefetch_related・preload・バッチ取得)に分けるのが無難です。

Hibernate では、List(bag)のコレクションを2つ同時に JOIN FETCH すると、次の例外になります(Hibernate 7.4.13 のソースのメッセージ)。

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [...]

1対多の JOIN と LIMIT・ページング(HHH000104 / HHH90003004)

1対多を JOIN した状態で LIMIT 20 を付けると、DBは「投稿20件」ではなく「JOIN 後の20行」を返します。ORM ごとの扱いは次のとおりです。

ORM 1対多を JOIN してページングしたとき
SQLAlchemy LIMIT 付きの親をサブクエリにしてから JOIN する(下の SQL)
Hibernate 5 警告 HHH000104 を出し、全件を読み込んでからメモリ上でページング
Hibernate 6・7 警告 HHH90003004(同じ内容)。設定で例外にもできる
共通の回避策 親の ID だけをページングで取り、関連は IN句で取る2段階

SQLAlchemy の joinedload(Post.comments) に limit(3) を付けたときのSQLです。

SELECT anon_1.id, anon_1.user_id, anon_1.title, users_1.id AS id_1, users_1.name, comments_1.id AS id_2, comments_1.post_id, comments_1.body
FROM (SELECT posts.id AS id, posts.user_id AS user_id, posts.title AS title
FROM posts ORDER BY posts.id
 LIMIT %(param_1)s::INTEGER) AS anon_1 LEFT OUTER JOIN users AS users_1 ON users_1.id = anon_1.user_id LEFT OUTER JOIN comments AS comments_1 ON anon_1.id = comments_1.post_id ORDER BY anon_1.id, comments_1.id

Hibernate 7.4.13 のソースでは、警告と、例外にする設定は次のように定義されています。

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
# 警告の代わりに例外にする(既定は false)
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true

有効にすると、setFirstResult() or setMaxResults() specified with collection fetch join (in-memory pagination was about to be applied, but 'hibernate.query.fail_on_pagination_over_collection_fetch' is enabled) という HibernateException になります。メモリ上のページングは、全件をアプリに読み込んでから切り出すため、件数が増えるほど遅くなり、メモリも使います。 2段階にする書き方の例です。

// 1段目:親の ID だけをページングで取る
@Query("SELECT p.id FROM Post p ORDER BY p.id")
Page<Long> findPageIds(Pageable pageable);

// 2段目:その ID の投稿とコメントをまとめて取る
@Query("SELECT DISTINCT p FROM Post p LEFT JOIN FETCH p.comments WHERE p.id IN :ids ORDER BY p.id")
List<Post> findWithCommentsByIdIn(@Param("ids") List<Long> ids);

default_batch_fetch_size を設定し、1段目でエンティティごとページングして、コメントはバッチ取得に任せる方法もあります。Pageable と Slice の違いは、関連記事「Spring Data JPAページング|Pageable・Slice・ソート」で扱っています。

IN句に入れられる件数には上限がある

PostgreSQL の通信方式では、1つのSQLに付けられるパラメーター($1、$2…)は 65,535個までです。node-postgres で試した結果です。

65535 ok 1000
65536 error: bind message supplies 0 parameters, but prepared statement "" requires 65536
ANY($1) 100000 -> 1000

65,536個で失敗し、配列1つを渡す = ANY($1) なら10万件でも1本で通りました。 SQLAlchemy の selectinload が500件ずつ、Hibernate のバッチ取得が指定した件数ずつに分けるのは、このような上限とSQLの長さを抑えるためでもあります。

使わない関連まで読む

N+1 を避けようとして、画面で使わない関連まで常に先読みすると、今度は不要なSQLと転送が増えます。先読みは「その画面・そのAPIで使う関連だけ」に付けます。JPA で関連を EAGER にするのが勧められないのも、このためです。

データベース以外のN+1問題

ループの中で、外部の API や別のサービスを1件ずつ呼ぶ場合も同じ構造です。

// N+1:注文100件に対して、在庫APIを100回呼ぶ
for (const order of orders) {
  order.stock = await stockApi.get(order.productId);
}

// 改善:まとめて取得するAPIを1回呼ぶ
const stocks = await stockApi.getMany(orders.map((o) => o.productId));

API の1往復は、DB より長いことがほとんどです。まとめて取れる API(バルクAPI)を用意する、並列で呼ぶ、キャッシュを使う、といった対策を選びます。

N+1問題を許容してよい場合

  • N が小さく、増えないことが決まっている(設定項目の一覧など)
  • 関連データがキャッシュから返る(同じ著者が何度も出る場合。ORM のセッション内キャッシュで2回目以降のSQLが出ないこともある)
  • 詳細画面のように、対象が1件だけ

「今は件数が少ない」は将来変わります。許容する場合も、理由をコメントに残しておくと後から見直せます。

AIが書いたコードでも起きやすい

コーディング支援の AI が生成したコードも、ループの中で関連に触れる素直な書き方になりやすく、N+1 が含まれることがあります。結果は正しいため、動作のテストでは見つかりません。一覧を返す処理のレビューでは「ループの中でSQLや API 呼び出しが発生していないか」を確かめ、前述の「遅延読み込みを例外にする設定」と「本数を固定するテスト」で仕組みとして防ぎます。

よくある質問

Q. n+1問題とは何ですか? A. 一覧を取る1本のSQLのあと、一覧の行ごとに関連データを取るSQLがN本発行され、合計 N+1 本になる性能問題です。ORM の遅延読み込みと、ループの中での関連へのアクセスが原因です。

Q. 「n + 1問題」「1+N問題」と書くこともありますか? A. どれも同じ問題を指します。最初の1本と後のN本という順番から「1+N問題」と呼ぶこともあります。

Q. N+1問題はどのくらいの件数から問題になりますか? A. 往復時間によります。実測では、往復2.9msの接続で N=10 でも 89.3ms(直した後は12.5ms)、N=100 で 725.2ms でした。件数が増える一覧では、件数に関係なく先読みにしておくのが基本です。

Q. インデックスを張れば解決しますか? A. 1本ごとは速くなりますが、本数は変わりません。実測でも、DB内の実行時間は200本で約3.3msと小さく、時間の大半は往復とORMの処理でした。

Q. 生のSQLを書いていれば、N+1問題は起きませんか? A. ループの中でSQLを実行すれば起きます。JDBC の実測では、1000件・往復2.9msで2,924.8msかかりました。

Q. FetchType.EAGER にすれば解決しますか? A. 解決しません。JPQL の一覧取得のあと、EAGER の関連が1件ずつ取られることがあります。LAZY のまま、画面ごとに JOIN FETCH・@EntityGraph・バッチ取得を使います。

Q. HHH000104 の警告が出ました。 A. コレクションを JOIN FETCH したクエリにページングを付けたため、全件を読み込んでメモリ上でページングしている、という警告です(Hibernate 6 以降は HHH90003004)。親の ID を先にページングで取り、関連を IN句やバッチ取得で取る形に変えます。

まとめ

  • N+1問題は、一覧1本+関連N本でSQLが増える問題。時間は「本数 × 往復時間」で伸びる
  • 実測では1000件・往復2.9msで、N+1 が約7.5秒、直した書き方が約0.1秒
  • DB内の実行時間は小さいため、スロークエリログではなく、SQLの本数(ログ・pg_stat_statements の calls・Debug Toolbar の similar)で見つける
  • 直し方は、多対1は JOIN、1対多・多対多は IN句。JPA は JOIN FETCH・@EntityGraph・バッチ取得、Django は select_related・prefetch_related、SQLAlchemy は joinedload・selectinload、GraphQL は DataLoader
  • 直しすぎに注意:1対多を2つ JOIN すると行が掛け算になる。1対多の JOIN とページングは HHH000104 / HHH90003004。IN句のパラメーターは 65,535個まで
  • 遅延読み込みを例外にする設定と、本数を固定するテストで、後から入り込むのを防ぐ

参考・出典

確認日はいずれも 2026年10月16日です。

  • SQLAlchemy 2.1 Documentation「Relationship Loading Techniques」:https://docs.sqlalchemy.org/en/21/orm/queryguide/relationships.html
  • Django 5.2 documentation「QuerySet API reference(select_related・prefetch_related)」:https://docs.djangoproject.com/en/5.2/ref/models/querysets/
  • Django 5.2 documentation「Testing tools(assertNumQueries)」:https://docs.djangoproject.com/en/5.2/topics/testing/tools/
  • Django Debug Toolbar documentation:https://django-debug-toolbar.readthedocs.io/
  • PostgreSQL 16 文書「pg_stat_statements」:https://www.postgresql.org/docs/16/pgstatstatements.html
  • Hibernate ORM 7.4 ソースコード(QueryLogging・QuerySettings・FetchSettings・MultipleBagFetchException):https://github.com/hibernate/hibernate-orm
  • Hibernate ORM User Guide(Fetching):https://docs.hibernate.org/orm/7.4/userguide/html_single/
  • Spring Data JPA Reference「JPA Query Methods(@EntityGraph)」:https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
  • MyBatis 3「Mapper XML Files(association・N+1 Selects Problem)」:https://mybatis.org/mybatis-3/sqlmap-xml.html
  • Rails Guides「Active Record Query Interface(Eager Loading Associations)」:https://guides.rubyonrails.org/active_record_querying.html
  • Laravel 13.x「Eloquent: Relationships(Eager Loading)」:https://laravel.com/docs/13.x/eloquent-relationships
  • Drizzle ORM「Relational queries」:https://orm.drizzle.team/docs/rqb
  • Prisma ORM 7「Relation queries」:https://www.prisma.io/docs/orm/v7/prisma-client/queries/relation-queries
  • DataLoader(GitHub graphql/dataloader):https://github.com/graphql/dataloader

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「N+1問題とは?原因・見つけ方・解決方法を実測比較【ORM別】」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?