Rails includes vs preload vs eager_load の違い|N+1問題

Rails includes vs preload vs eager_load の違い|N+1問題

Rails ActiveRecord でアソシエーション先のレコードを取得する時の悩み:

@posts = Post.all
@posts.each do |post|
  puts post.author.name   # ← N+1問題発生!
end

これを解決するための3兄弟が includes / preload / eager_load:

Post.includes(:author).all      # 推奨
Post.preload(:author).all       # 別クエリ
Post.eager_load(:author).all    # LEFT OUTER JOIN

しかし、

  • includespreload の違いは?
  • eager_load はいつ使う?
  • SQL が違うの?性能は?
  • なぜ includes(:author).where(authors: { ... }) だと挙動が変わる?
  • references ってなんで必要?
  • joins も似てるけど何が違う?
  • N+1 がいつ発生してるか分からない

など、Rails パフォーマンスの最重要テーマで躓くポイントが多くあります。

本記事では、includes / preload / eager_loadすべての違いを、N+1問題の解説から、生成SQL、includes の動的切り替え、references、joins との違い、パフォーマンス比較、Bullet gem 活用、使い分け指針、ありがちな間違いまで完全網羅します。この1本で Rails パフォーマンスチューニングのN+1対策をマスターできます。


目次

結論:3つの違いを一発で理解

時間がない方向けに、最重要ポイントを先に示します。

早見表

観点preloadincludeseager_load
クエリ方式別クエリ(複数)動的に判断LEFT OUTER JOIN(1クエリ)
生成SQL2クエリ(IN句)preload or eager_loadJOIN 1クエリ
関連テーブルでフィルタ不可可(自動でeager_load)
メモリ使用効率的状況によるやや多い
使うべきタイミング単純な事前読み込み通常はこれ(推奨)関連テーブルでフィルタ・ソート

一言で言うと

  • preload: 別クエリで2回SELECT
  • eager_load: LEFT OUTER JOIN で1回
  • includes: Rails が状況見て自動選択(通常はこれを使えばOK

詳細は以下で解説します。


まず押さえる:N+1問題とは

問題のあるコード

# コントローラ
def index
  @posts = Post.all
end
<%# views/posts/index.html.erb %>
<% @posts.each do |post| %>
  <p><%= post.title %> by <%= post.author.name %></p>
<% end %>

発生する SQL

# 最初の1クエリ:Posts取得
SELECT "posts".* FROM "posts"

# 各 post の author 取得(N回)
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
...
SELECT "users".* FROM "users" WHERE "users"."id" = 100 LIMIT 1

1 + N クエリ発生。Posts が100件あれば101回DB接続。

なぜ問題か

  • DB接続のオーバーヘッド: 1回ごとにラウンドトリップ
  • N に比例: データ量が増えるほど致命的
  • ユーザー体験悪化: ページロード遅延
  • DBサーバー負荷: 大量の小さなクエリ

解決:Eager Loading

@posts = Post.includes(:author).all

2クエリで完結:

SELECT "posts".* FROM "posts"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ..., 100)

100件でも 2クエリ。これがEager Loading(事前読み込み)


preload の挙動

基本

Post.preload(:author).all

生成SQL

-- 1. Posts 取得
SELECT "posts".* FROM "posts"

-- 2. 関連する Users を IN句で一括取得
SELECT "users".* FROM "users"
WHERE "users"."id" IN (1, 2, 3, ...)

常に2クエリ。Rails がメモリ上でデータをマージしてくれます。

preload の特徴

  • 必ず別クエリ(JOIN しない)
  • シンプルで予測可能
  • メモリ効率が良い(必要なカラムだけ取得)
  • 大量データに強い

制限:関連テーブルでフィルタ不可

# ❌ エラーになる
Post.preload(:author).where("users.name = ?", "Alice")
# => ActiveRecord::StatementInvalid: PG::UndefinedColumn: ERROR: missing FROM-clause entry for table "users"

preload は別クエリで取得するため、メインのクエリ(posts)に関連テーブル(users)への参照は含められません。

使い分け

# ✅ シンプルな表示
@posts = Post.preload(:author)
@posts.each { |p| p.author.name }

# ❌ 関連テーブルでフィルタしたい場合は使えない

eager_load の挙動

基本

Post.eager_load(:author).all

生成SQL

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"."author_id"

1つのクエリで一気に取得(LEFT OUTER JOIN)。

eager_load の特徴

  • 常に1つの JOIN クエリ
  • 関連テーブルでフィルタ・ソート可能
  • 大量データだと冗長データが多くなる(メモリ消費増)
  • 複雑な SQL になりがち

関連テーブルでフィルタ可能

# ✅ OK
Post.eager_load(:author).where("users.active = ?", true)

# ✅ ORDER BY 関連カラム
Post.eager_load(:author).order("users.name ASC")

eager_load のデメリット

-- LEFT OUTER JOIN は重複データを多く返す
-- たとえば1ユーザーに10投稿があると、users列の同じデータが10行繰り返される

メモリ上では重複データの処理コストがかかります。

使い分け

# ✅ 関連テーブルでフィルタ
Post.eager_load(:author).where("users.active = ?", true)

# ✅ 関連テーブルでソート
Post.eager_load(:author).order("users.name DESC")

includes の挙動(動的)

基本

Post.includes(:author).all

includes は状況に応じて preload / eager_load を選ぶ

# ケース1: 関連テーブルに触れていない → preload として動作
Post.includes(:author)
# SQL:
# SELECT * FROM posts
# SELECT * FROM users WHERE id IN (...)

# ケース2: 関連テーブルでフィルタ → eager_load として動作
Post.includes(:author).where("users.name = ?", "Alice")
# SQL:
# SELECT ... FROM posts LEFT OUTER JOIN users ON ...
# WHERE users.name = 'Alice'

Rails が賢く判断してくれる。

references で明示

Rails 4以降、ハッシュ形式の where 以外(”users.name” 等の文字列)では references 指定が必要:

# ❌ Rails 4+ で警告
Post.includes(:author).where("users.name = ?", "Alice")

# ✅ references 追加
Post.includes(:author).where("users.name = ?", "Alice").references(:users)

# または条件をハッシュで(references 不要)
Post.includes(:author).where(authors: { name: "Alice" })

includes の使い分け

# 通常はこれで OK
Post.includes(:author)

# 関連テーブル条件もOK
Post.includes(:author).where(authors: { active: true })

# Rails が「joinが必要」と判断して eager_load に切り替える

生成SQLの比較

同じ目的の処理を3メソッドで実行した時の SQL を見比べます。

シンプルな取得

Post.preload(:author).all
SELECT "posts".* FROM "posts";
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3);
Post.includes(:author).all
-- preload と同じ(フィルタしてないため)
SELECT "posts".* FROM "posts";
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3);
Post.eager_load(:author).all
SELECT "posts"."id" AS t0_r0, ..., "users"."id" AS t1_r0, ...
FROM "posts"
LEFT OUTER JOIN "users" ON "users"."id" = "posts"."author_id";

関連テーブルでフィルタ

# preload
Post.preload(:author).where("users.active = true")
# => ERROR: missing FROM-clause entry for table "users"

# includes(自動でeager_load)
Post.includes(:author).where(authors: { active: true })
SELECT "posts"."id" AS t0_r0, ..., "users"."id" AS t1_r0, ...
FROM "posts"
LEFT OUTER JOIN "users" ON "users"."id" = "posts"."author_id"
WHERE "users"."active" = TRUE;
# eager_load(明示的)
Post.eager_load(:author).where("users.active = true")
-- includes と同じ
SELECT "posts"."id" AS t0_r0, ...
FROM "posts"
LEFT OUTER JOIN "users" ON ...
WHERE "users"."active" = TRUE;

joins との違い

joinsINNER JOIN を発行するが、関連レコードはロードしない:

Post.joins(:author).where(users: { active: true })

生成SQL

SELECT "posts".* FROM "posts"
INNER JOIN "users" ON "users"."id" = "posts"."author_id"
WHERE "users"."active" = TRUE;

注目: posts.* のみ取得。users カラムは SELECT に含まれない。

joins の特徴

  • INNER JOIN(条件を満たすレコードのみ)
  • 関連レコードはメモリに事前ロードしない
  • N+1 を解決しない(次に post.author を呼ぶとクエリ発生)
  • フィルタリング目的に最適

早見表

メソッドJOIN種類関連レコードのロードフィルタ用途
joinsINNER JOINしないできるフィルタリングのみ
preloadなし(2クエリ)するできない単純なEager Loading
eager_loadLEFT OUTER JOINするできるフィルタ+Eager
includes自動するできるデフォルト

joins + preload の組み合わせ

# joins でフィルタ + preload で事前ロード
Post.joins(:author)
    .where(users: { active: true })
    .preload(:author)

joins でアクティブユーザーの投稿に絞り、preload で author を事前ロード。重複なしでメモリ効率最良。


ネストした関連のEager Loading

複数階層・複数関連も対応。

階層関連

# Post → Author → Country
Post.includes(author: :country).all
-- preload 形式(条件ないため)
SELECT * FROM posts
SELECT * FROM users WHERE id IN (...)
SELECT * FROM countries WHERE id IN (...)

複数の関連

# Post に author と comments
Post.includes(:author, :comments).all
SELECT * FROM posts
SELECT * FROM users WHERE id IN (...)
SELECT * FROM comments WHERE post_id IN (...)

階層 + 複数

Post.includes(:author, comments: :user).all
SELECT * FROM posts
SELECT * FROM users WHERE id IN (...)   -- author
SELECT * FROM comments WHERE post_id IN (...)
SELECT * FROM users WHERE id IN (...)   -- comments.user

Hash で複雑な構造

Post.includes(
  :tags,
  author: [:country, :profile],
  comments: [user: :avatar]
)

パフォーマンス比較

投稿100件 + 各投稿に1著者の場合

方法クエリ数データ転送量メモリ
N+1(何もしない)101
preload2効率的
includes(preload動作)2効率的
eager_load1多(JOIN重複)やや多

大量データのケース

# 100,000 posts、各 author あり

# preload
Post.preload(:author).all
# 2クエリ、しかし IN句が長大

# eager_load
Post.eager_load(:author).all
# 1クエリ、posts × 1の単純な JOIN なので問題なし

# includes(preload動作)
Post.includes(:author).all
# preload と同じ

多対多の落とし穴

# Post has_many :comments
# 100 posts、各 10 comments

# eager_load
Post.eager_load(:comments).all
# LEFT OUTER JOIN で 100 × 10 = 1000行
# posts のデータが10倍重複

# preload
Post.preload(:comments).all
# 2クエリ、重複なし

has_many の eager_load は冗長データが多くなる

ベンチマーク参考

require "benchmark"

Benchmark.bm do |x|
  x.report("N+1") do
    Post.limit(100).each { |p| p.author.name }
  end
  
  x.report("preload") do
    Post.preload(:author).limit(100).each { |p| p.author.name }
  end
  
  x.report("eager_load") do
    Post.eager_load(:author).limit(100).each { |p| p.author.name }
  end
  
  x.report("includes") do
    Post.includes(:author).limit(100).each { |p| p.author.name }
  end
end

実測すると:

  • N+1: 数十〜数百ms
  • eager_load/includes/preload: 数ms

大幅な改善が確認できます。


Bullet gem で N+1 を検出

開発・テスト時にN+1問題を自動検出してくれる gem:

# Gemfile
group :development, :test do
  gem "bullet"
end
# config/environments/development.rb
config.after_initialize do
  Bullet.enable        = true
  Bullet.alert         = true    # ブラウザに警告表示
  Bullet.bullet_logger = true    # log/bullet.log に出力
  Bullet.console       = true    # ブラウザコンソールに出力
  Bullet.rails_logger  = true    # Rails ログに出力
  Bullet.add_footer    = true    # ページフッターに表示
end

検出される警告例

USE eager loading detected
  Post => [:author]
  Add to your query: .includes([:author])

Unused eager loading detected
  Post => [:tags]
  Remove from your query: .includes([:tags])

Unused detection(不要なEager Load)

# 使わない関連をeager_loadしている
Post.includes(:tags).each { |p| puts p.title }  # tags 使ってない

→ Bullet が「不要な includes」を警告。

CI 統合

# spec/rails_helper.rb
RSpec.configure do |config|
  config.before(:each) do
    Bullet.start_request
  end
  
  config.after(:each) do
    Bullet.perform_out_of_channel_notifications if Bullet.notification?
    Bullet.end_request
  end
end

テストでN+1検出 → 修正、というワークフローが可能。


ありがちな間違い

1. preload で関連テーブルフィルタ

# ❌ エラー
Post.preload(:author).where("users.active = true")

includeseager_load を使う:

Post.includes(:author).where(authors: { active: true })
# または
Post.eager_load(:author).where(authors: { active: true })

2. references 漏れ

# ❌ Rails 4+ で警告
Post.includes(:author).where("users.name = ?", "Alice")

# ✅ references を追加
Post.includes(:author).where("users.name = ?", "Alice").references(:users)

# または Hash で(references 不要)
Post.includes(:author).where(authors: { name: "Alice" })

3. 不要な eager loading

# ❌ author を使わないのに includes
Post.includes(:author).each { |p| puts p.title }

→ 不要。Bullet gem で検出可能。

4. ループ内での再ロード

@posts = Post.includes(:author).all
@posts.each do |post|
  # ✅ OK
  post.author.name
  
  # ❌ 別の関連にアクセス(再N+1)
  post.comments.count
end

→ 必要な関連をすべて includes:

@posts = Post.includes(:author, :comments).all

5. count での落とし穴

# ❌ 全件取得してから数える
posts = Post.includes(:author).where(active: true)
puts posts.count   # 別のCOUNTクエリ発生(includes無視)

# ✅ Bullet 等で確認しつつ
posts.size         # ロード済みなら配列のsize(高速)

6. eager_load + ページネーション

# ⚠️ LEFT OUTER JOIN なので LIMIT が予想と違うことも
Post.eager_load(:comments).limit(10)
# 10件のpost ではなく、JOIN結果10行(comments分散)

# ✅ サブクエリ等で工夫
posts = Post.limit(10).pluck(:id)
Post.eager_load(:comments).where(id: posts)

7. 単一レコードの取得後

# 1件取得時にincludes
post = Post.includes(:comments).find(1)

# でも eager_load の方が確実
post = Post.eager_load(:comments).find(1)

1件取得時は eager_load の方が予測しやすいケースも。

8. polymorphic 関連

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

# ✅ polymorphic も includes 可能
Comment.includes(:commentable).all
# 各 commentable タイプごとに別クエリ発行

実践パターン

パターン1:シンプルな一覧表示

def index
  @posts = Post.includes(:author).order(created_at: :desc).limit(20)
end

パターン2:絞り込み + Eager Loading

def published
  @posts = Post.includes(:author)
               .where(published: true)
               .where(authors: { active: true })
               .order(:created_at)
end

パターン3:複数階層

def show
  @post = Post.includes(
    :tags,
    author: :profile,
    comments: [:user, :likes]
  ).find(params[:id])
end

パターン4:API レスポンス

def index
  @posts = Post.includes(:author).page(params[:page])
  render json: @posts.as_json(include: :author)
end

パターン5:count を含む集計

# 各 post の comment 数を一緒に取得
@posts = Post.left_joins(:comments)
             .select("posts.*, COUNT(comments.id) AS comments_count")
             .group("posts.id")
             .includes(:author)

パターン6:CSV エクスポート

def export
  CSV.generate do |csv|
    csv << ["Title", "Author", "Tags"]
    Post.includes(:author, :tags).find_each(batch_size: 100) do |post|
      csv << [post.title, post.author.name, post.tags.map(&:name).join(",")]
    end
  end
end

find_each でメモリ効率も担保。


ベストプラクティス

1. デフォルトは includes

迷ったら includes。Rails が最適化してくれる。

2. 関連テーブルでフィルタするなら hash形式

# ✅ 推奨
Post.includes(:author).where(authors: { active: true })

# ❌ references 必要
Post.includes(:author).where("users.active = ?", true).references(:users)

3. Bullet gem を必ず導入

開発・テストで N+1 を検出。

4. 大量データは find_each

Post.includes(:author).find_each(batch_size: 1000) do |post|
  # 処理
end

5. preload を明示するケース

# has_many の関連で重複データを避けたい
Post.preload(:comments).all
# preload なら重複なし

6. eager_load を明示するケース

# 関連テーブルでフィルタ・ソートが必要
Post.eager_load(:author).order("users.name")

7. joins + preload の組み合わせ

# joins でフィルタ、preload で事前ロード
Post.joins(:author).where(users: { active: true }).preload(:author)

8. console で SQL を確認

# bin/rails c
Post.includes(:author).to_sql   # Rails 6+
# 確認しながら設計

詳細はrails console 使い方の記事


よくある質問(FAQ)

Q1. 結局どれを使えばいい?

通常は includes でOK。

Post.includes(:author)

Rails が状況見て preload/eager_load を選んでくれる。

Q2. preload と includes の違いを実感したい

# 関連テーブルフィルタが必要な場合
Post.preload(:author).where(authors: { active: true })
# => エラー

Post.includes(:author).where(authors: { active: true })
# => OK(自動でeager_load)

Q3. references は必要?

Hash形式の where なら不要。文字列形式なら必要:

# ✅ Hash → 不要
Post.includes(:author).where(authors: { active: true })

# ❌ 文字列 → 必要
Post.includes(:author).where("users.active = true").references(:users)

Q4. eager_load が遅い

LEFT OUTER JOIN で関連レコードが多いと冗長データが膨らみます。

# 1 post に100 comment → JOIN結果100行
Post.eager_load(:comments)

# preload なら2クエリで重複なし
Post.preload(:comments)

has_many で件数が多い場合は preload 推奨

Q5. N+1 がいつ起きているか確認

# Bullet gem 導入
gem "bullet", group: [:development, :test]

Rails ログにも:

# 開発環境のログ
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
# ↑ 同じテーブルへの個別クエリが連続 → N+1のサイン

Q6. joins だけで N+1 解決できない理由

posts = Post.joins(:author).where(users: { active: true })
posts.each { |p| p.author.name }
# joins は author をロードしない → 個別クエリ発生

→ joins + preload か、includes 推奨。

Q7. polymorphic 関連の Eager Loading

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

Comment.includes(:commentable).all
# commentable_type ごとに別クエリ

Q8. 仮想カラム(counter_cache)の活用

class Post < ApplicationRecord
  has_many :comments, counter_cache: true
end
post.comments.count   # COUNT クエリ発生
post.comments_count   # counter_cache 利用、追加クエリなし

Q9. has_many :through との組み合わせ

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
end

User.includes(:groups)
# memberships と groups の両方が事前ロード

Q10. select で必要なカラムだけ

Post.includes(:author).select(:id, :title, :author_id)
# posts は3カラムだけ、author は全カラム

メモリ節約に有効。ただし associations の正しい動作には外部キーカラム必須。

Q11. order と includes

Post.includes(:author).order(:created_at)
# posts.created_at で並び替え
# preload 動作

Post.includes(:author).order("users.name")
# 関連カラムでORDER → eager_load 動作

Q12. group / having と includes

# 集計 + Eager Loading
Post.includes(:author)
    .group("posts.author_id")
    .having("COUNT(posts.id) > 5")
    .pluck("posts.author_id")

複雑な集計は SQL を組み立てる方が確実。


関連メソッド早見表

メソッド役割SQL
includes動的Eager Loadingpreload or eager_load
preload別クエリEager Loading2クエリ(IN句)
eager_load単一クエリ Eager LoadingLEFT OUTER JOIN
joinsフィルタ用JOININNER JOIN
left_joinsLEFT JOIN(プレロードなし)LEFT OUTER JOIN
referencesincludes の eager_load 強制
eager_load_values設定済みの eager_load 参照

参考リンク・関連資料

Rails 公式

関連 Gem

関連記事(本サイト)


まとめ

includes / preload / eager_load の違いを理解することは、Rails パフォーマンスチューニングの最重要スキル。要点を再整理します。

  • N+1問題: 1+N回のクエリ発生、Rails で最も多い性能問題
  • includes: 状況見て自動判断(通常はこれを使う
  • preload: 必ず別クエリ、関連テーブルフィルタ不可、メモリ効率良い
  • eager_load: 必ず LEFT OUTER JOIN、関連テーブル条件OK
  • joins: INNER JOIN だが事前ロードしない(フィルタ用)

SQL の違い

preload    # 2クエリ(IN句)
eager_load # 1クエリ(LEFT OUTER JOIN)
includes   # preload or eager_load(動的)
joins      # INNER JOIN(事前ロードなし)

references の必要性

includes + Hash形式where     → references 不要
includes + 文字列where       → references 必要

使い分け

状況推奨
単純なEager Loadingincludes
関連テーブルでフィルタincludes + hash where
has_many で重複嫌うpreload
LIKE 等で関連テーブル文字列条件eager_load
フィルタのみjoins
検出Bullet gem

これらの知識は、Rails アプリのパフォーマンス改善・コードレビュー・スケーラビリティ確保など、あらゆる場面で活用できます。本記事をブックマークしておけば、N+1問題に遭遇した時の対応が大幅に効率化されます。


本記事は2026年6月時点の情報をもとに、Ruby on Rails 7.x / 8.x での動作確認・公式ドキュメントに基づき作成しています。Rails のバージョンによって挙動が異なる場合があるため、最新の情報はRails公式ガイドもあわせてご確認ください。