投稿日:2026/7/27
更新日:2026/7/27

ActiveRecord で N+1 問題を潰すための3つのメソッドのまとめ。
どれも「関連(association)を事前に読み込んでキャッシュする」点は同じで、発行するクエリの形が違う。
用語の前提だけ先に置いておく。
| 用語 | 意味 |
|---|---|
| association(関連) | 関連先のテーブルのこと。User.preload(:posts) でいう posts にあたる |
| キャッシュ | 取得済みのレコードをメモリに保持しておき、2回目以降の参照でクエリを発行しないこと |
| eager loading | ループに入る前に関連をまとめて読み込んでおくこと。preload / eager_load / includes がやっているのはこれ |
ループの中で1件ずつクエリが飛び、クエリ数がレコード数に比例して増えてしまう問題。
会員(users)と投稿(posts)が 1対多 で関連づいているとする。
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
end
投稿一覧で「投稿のタイトル」と「投稿者名」を出す。
# posts_controller.rb
def index
@posts = Post.all
end
<%# views/posts/index.html.erb %>
<% @posts.each do |post| %>
<%= post.title %>
<%= post.user.name %> <%# ← ここで毎回クエリが飛ぶ %>
<% end %>
発行されるクエリ:
-- 投稿を取る 1 回
SELECT "posts".* FROM "posts"
-- 投稿の件数(N)だけ繰り返される
SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1
...
1 + N 回発行されるので N+1 問題と呼ばれる。1クエリ自体は 0.1ms でも、100件あれば往復コストが積み上がってレスポンスが目に見えて遅くなる。
指定した関連を別クエリで引いてキャッシュする。まず主テーブルを取り、その id を IN 句に並べて関連を取る。
User.preload(:posts)
# SELECT `users`.* FROM `users`
# SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, ...)
2クエリで済み、ループ中に追加のクエリは飛ばない。
User.preload(:posts).where(posts: { published: true }) はエラーになる注意: IN 句が大きくなりすぎるケース
主テーブルのレコード数が多いとIN (...)に大量の値が並び、SQL 文自体が肥大化する。
MySQL ではmax_allowed_packetを超えるとMysql2::Errorになるなど、DB 側の設定値に当たることがある。
件数が読めない一覧ではfind_eachなどでバッチに区切るか、そもそも件数を絞る。
左外部結合(LEFT OUTER JOIN)で主テーブルと関連を1クエリにまとめて取得する。
Post.all.eager_load(:user)
# SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, ... ,
# "users"."id" AS t1_r0, "users"."name" AS t1_r1, ...
# FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"
左外部結合は、2つのテーブルを結合キーで対応づけて1行に連結するもので、右側に一致する相手がいなかった行も残るのが特徴(この場合 user が nil の投稿も消えない)。INNER JOIN だと消えてしまうので、eager loading にはこちらが使われる。
Post.eager_load(:user).where(users: { active: true })
t0_r0 のような別名が振られるのは、両テーブルの id や name の衝突を避けるためincludes は自前のクエリ戦略を持たず、状況に応じて preload か eager_load のどちらかに落ちる。
includes した関連に対して references または joins を呼んでいるreferences が必要)eager_load を明示的に呼んでいる# preload 相当(2クエリ)
User.includes(:posts)
# SELECT `users`.* FROM `users`
# SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, ...)
# eager_load 相当(LEFT OUTER JOIN 1クエリ)
User.includes(:posts).references(:posts).where("posts.published = ?", true)
joins はデフォルトで INNER JOIN を行う。他の3つとの最大の違いは 関連をキャッシュしないこと。
Post.joins(:user).where(users: { active: true })
# SELECT "posts".* FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id"
# WHERE "users"."active" = TRUE
キャッシュしないので eager loading の代わりにはならない(ループ中に post.user を触れば N+1 が復活する)。
一方で ActiveRecord オブジェクトを生成しない分、メモリ消費は抑えられる。
関連先のレコードにアクセスせず、単に絞り込みたいだけなら
joinsが適している。
| クエリの形 | 関連先で絞り込み | キャッシュ | |
|---|---|---|---|
| preload | 関連ごとに分割 | ✗ | ○ |
| eager_load | LEFT OUTER JOIN で1本 | ○ | ○ |
| includes | 状況により上のどちらか | ○ | ○ |
| joins | INNER JOIN で1本 | ○ | ✗ |
関連が複数あるとき、個別に最適化できない
Post.includes(:user, :comments)
「user は eager_load、comments は preload」という振り分けができない。
1つでも eager_load すべき関連があれば、すべて LEFT OUTER JOIN にまとめられる。
本来クエリを分けたほうが速い関連まで JOIN に巻き込まれ、かえって遅くなることがある。
どちらに落ちるかがコード上から読み取りにくい
条件次第でクエリが変わるため、preload と eager_load の判定基準を正確に把握していないと、
データ量が増えたときに挙動をコントロールしづらい。
意図が明確なぶん、preload / eager_load を明示的に書き分けるほうが安全。
eager_load は常に結合するため、has_many を含むとデータ量が大きいときに重いクエリになる。
クエリを分割したほうがレスポンスが速いことが多い。
1:1・N:1 なら外部結合しても取得行数は増えないので、クエリを分けるより 1 本で取るほうが速い。
そもそも事前読み込みが要らない場面のほうが多い。ループ中に関連を触らないなら付けないのが基本で、
使わない関連を preload するのは、無駄なクエリと無駄なオブジェクト生成を増やすだけになる。
# 自テーブルのカラムしか表示しない一覧。preload は不要
Post.order(created_at: :desc).page(params[:page])
# 絞り込みたいだけで post.user は触らない → joins(キャッシュ不要)
Post.joins(:user).where(users: { active: true })
# そもそもモデルを組み立てない
Post.count
Post.pluck(:title)
Post.all のような素の取得が問題になるのは、次の2つのときだけ。
each の中で関連を触っている(= N+1 の本体)なお Model.all は配列ではなく ActiveRecord::Relation を返し、この時点ではクエリが飛ばない。count / pluck / find_each の起点や、scope 内の条件分岐の初期値として普通に使う。
→ 遅延評価そのものについては別ノート ActiveRecord Relation の遅延評価 に切り出す。
Rails 6.1 以降、事前読み込みしていない関連にアクセスした時点で例外を投げさせられる。
「N+1 に気付かないまま本番でレコードが増えて顕在化する」という一番厄介なパターンを、開発時に落とせる。
Post.strict_loading.each do |post|
post.user.name # ActiveRecord::StrictLoadingViolationError
end
モデル単位・関連単位でも指定できる。
class Post < ApplicationRecord
self.strict_loading_by_default = true # このモデル全体に適用
has_many :comments, strict_loading: true # この関連だけ適用
end
アプリ全体に効かせるなら設定で入れる。開発・テスト環境だけ例外にして、本番はログに留める運用も取れる。
# config/environments/development.rb
config.active_record.strict_loading_by_default = true
config.active_record.action_on_strict_loading_violation = :raise # :log にすると警告のみ
includes は便利だが、複数関連を一括最適化できずクエリが読めなくなるため、明示的な使い分けを基本にするstrict_loading を入れておくと、事前読み込み漏れを実行時ではなく開発時に落とせる