Includes Vs Preload Vs Eager_load In Ruby On Rails
Learn the differences between includes, preload, and eager_load in Ruby on Rails to avoid N+1 queries and optimize performance.
• 13 min read
• 13 min read
Learn the differences between includes, preload, and eager_load in Ruby on Rails to avoid N+1 queries and optimize performance.
• 13 min read
• 13 min read
If you have spent any time working with Active Record, you have probably run into the N+1 query problem, even if you did not know it had a name at the time. Maybe your app felt fast in development with a handful of records, then slowed to a crawl in production once real data piled up. Nine times out of ten, the culprit is a loop quietly firing off dozens or hundreds of extra database queries.
Rails gives you three tools to fix this: includes, preload, and eager_load. They all solve the same basic problem: loading associated records ahead of time instead of one by one, but they do it in different ways and with different tradeoffs. This is exactly where a lot of Rails developers get confused. The methods look similar, they are often used interchangeably in tutorials, and the differences only really show up once you look at the generated SQL.
In this guide, we will break down what each method actually does under the hood, when Rails picks a JOIN versus separate queries, and how to decide which one fits your use case. By the end, you will be able to look at a slow Rails view, diagnose the N+1 problem, and fix it with confidence instead of guessing.
The N+1 query problem happens when your code runs one query to fetch a list of records, then runs an additional query for each record to fetch a related association. If you fetch 50 articles and then loop through them to print each author's name, and that author lookup was not eager-loaded, Rails will run one query for the articles and then 50 more queries, one per article, to fetch each author. That is 51 queries total instead of 2.
Every query has overhead: opening a connection to the database, parsing SQL, executing it, and returning results back to your Rails process. When that overhead is multiplied by hundreds of records, page load times balloon. In production, this often does not show up until your dataset grows past what you tested locally, which is why N+1 issues are notorious for slipping through code review.
Imagine a blog with an Article model that belongs to an Author.
class Article < ApplicationRecord
belongs_to :author
end
class Author < ApplicationRecord
has_many :articles
end
Now consider this view code:
<% Article.all.each do |article| %>
<p><%= article.title %> by <%= article.author.name %></p>
<% end %>
Running this code produces the following queries:
SELECT * FROM articles;
SELECT * FROM authors WHERE id = 1;
SELECT * FROM authors WHERE id = 2;
SELECT * FROM authors WHERE id = 3;
-- ...one query per article
If you have 100 articles, that is 101 total queries just to render a simple list. This is the exact scenario includes, preload, and eager_load are designed to prevent.
includes Do?includes is the most commonly used eager loading method in Rails, and it is intentionally a bit smart. It decides at runtime whether to load associations using two separate queries or a single query with a JOIN, depending on how you use the association afterward.
articles = Article.includes(:author)
articles.each do |article|
puts article.author.name
end
By default, includes runs two separate queries:
SELECT * FROM articles;
SELECT * FROM authors WHERE id IN (1, 2, 3, ...);
Rails fetches all the articles, collects the author IDs, and then runs a single query to grab all the matching authors. Active Record then stitches everything together in memory so calling article.author does not trigger a new query.
If you are only using the association for reading data, without filtering or ordering on its columns, includes stick with the two-query approach shown above. This is efficient and keeps the queries simple.
If you reference the associated table in a where clause or an order, Rails switches strategy and generates a LEFT OUTER JOIN instead.
Article.includes(:author).where(authors: { active: true })This produces:
SELECT articles.*, authors.*
FROM articles
LEFT OUTER JOIN authors ON authors.id = articles.author_id
WHERE authors.active = true;
This automatic switching is exactly why includes confuses so many developers. The same method call can behave completely differently depending on the rest of your query chain.
# Two queries, no JOIN
Article.includes(:author).to_a
# Single query with JOIN, because of the where clause
Article.includes(:author).where(authors: { active: true }).to_a
preload Do?preload is the more predictable, no-surprises sibling of includes. It always uses separate queries and never generates a JOIN, no matter what you do afterward.
articles = Article.preload(:author)
This always produces:
SELECT * FROM articles;
SELECT * FROM authors WHERE id IN (1, 2, 3, ...);
Even if you try to filter on the associated table, preload will not switch to a JOIN. In fact, doing so will raise an error because the associated table is not part of the query yet.
# This will raise an ActiveRecord::StatementInvalid error
Article.preload(:author).where(authors: { active: true })
# Good use case: displaying author names in a list, no filtering needed
@articles = Article.preload(:author)
eager_load Do?eager_load is the opposite of preload. It always forces a single query using a LEFT OUTER JOIN, regardless of whether you filter on the association or not.
articles = Article.eager_load(:author)
SELECT articles.*, authors.*
FROM articles
LEFT OUTER JOIN authors ON authors.id = articles.author_id;
Because it is a LEFT OUTER JOIN, articles without an author are still included in the results, with NULL values for the author columns.
where conditions.eager_load when you do not need JOIN, the behavior wastes database resources.# Good use case: filtering articles by an author attribute
Article.eager_load(:author).where(authors: { active: true })
Here is how the three methods stack up against each other once you look past the surface similarities.
Feature | includes | preload | eager_load |
|---|---|---|---|
Query strategy | Two queries, or JOIN if needed | Always two separate queries | Always a single LEFT OUTER JOIN |
Can filter by association columns | Yes, triggers a JOIN | No, raises an error | Yes, built for this |
Predictability | Conditional, depends on query chain | Always predictable | Always predictable |
Risk of duplicate data | Low, unless it switches to JOIN | None | Higher, since JOIN can duplicate rows |
Best for | General-purpose eager loading | Simple display-only associations | Filtering or ordering by association data |
Memory usage | Moderate | Lower, smaller separate result sets | Higher, wider result set from the JOIN |
preload and the default includes behavior both keep query count low and predictable, at two queries per association. eager_load and JOIN-mode includes collapse everything into one query, which sounds efficient but can actually return more data than needed because of duplicated columns.
JOIN-based approaches can use more memory because Active Record has to hydrate a wider result set, with the associated table's columns repeated across every row of the parent table. Separate queries avoid this repetition.
There is no universal winner here. Two smaller queries are sometimes faster than one large JOIN, especially when the associated table is large or when you are joining multiple associations at once. The right choice depends on your access pattern, not on which method sounds more advanced.
A simple decision guide:
includes as your default. It is smart enough to switch to a JOIN automatically when you filter by the association, and stays with separate queries otherwise.preload when you know you only need the association for display, and you want to guarantee simple, separate queries with zero surprises.eager_load when you specifically need to filter, sort, or query against columns on the associated table, and you want that guaranteed in a single query.preload or includes without filtering.eager_load, or includes combined with a where clause on the association.includes with multiple associations, letting Rails decide the strategy per association.class ArticlesController < ApplicationController
def index
# Straightforward listing, no filtering on author
@articles = Article.preload(:author, :category)
end
def active_authors
# Filtering by author attribute, needs a JOIN
@articles = Article.eager_load(:author).where(authors: { active: true })
end
end
Many developers assume includes always generates a single query with a JOIN. In reality, it only does this when your query chain references the associated table's columns. Otherwise, it quietly runs two separate queries.
# Wasteful if comments are never used in the view
Article.includes(:author, :category, :comments)
Only eager load what you actually use. Loading associations you never touch adds unnecessary query time and memory usage.
# Unnecessary JOIN if you are not filtering by author
Article.eager_load(:author)
If you do not need to filter or sort by the association, eager_load just adds overhead compared to preload or plain includes.
The Bullet gem detects N+1 queries in development and logs warnings, but many developers get used to seeing them and stop paying attention. Treat every Bullet warning as something to investigate before deploying.
Eager loading every association on every controller action "just in case" adds unnecessary database load. Eager load based on what a specific action actually renders, not based on what the model could theoretically need.
Article.select(:id, :title, :author_id).includes(:author)
Pulling only the columns you need reduces the amount of data Active Record has to hydrate into objects.
class Article < ApplicationRecord
scope :with_author_and_category, -> { includes(:author, :category) }
end
Article.published.with_author_and_category
Wrapping eager loading in scopes keeps controllers clean and makes the intent explicit.
Add the Bullet gem to your development and test environments to get automatic alerts whenever an N+1 query slips into your code.
# Gemfile
group :development do
gem "bullet"
end
Keep an eye on your Rails server logs during development. Every query Active Record generates is printed there, which makes it easy to spot repeated queries hitting the same table.
Use ActiveSupport::Notifications or simple Benchmark.measure blocks around suspect code paths to confirm that your optimization actually reduced query time, rather than assuming it did.
Benchmark.measure do
Article.includes(:author).to_a
end
Let's build out a small blog example with Article, Author, Category, and Comment models.
class Article < ApplicationRecord
belongs_to :author
belongs_to :category
has_many :comments
end
class Author < ApplicationRecord
has_many :articles
end
class Category < ApplicationRecord
has_many :articles
end
class Comment < ApplicationRecord
belongs_to :article
end
<% Article.all.each do |article| %>
<h2><%= article.title %></h2>
<p>By <%= article.author.name %> in <%= article.category.name %></p>
<p><%= article.comments.count %> comments</p>
<% end %>
For 50 articles, this generates roughly 1 plus 50 for authors, plus 50 for categories, plus 50 for comment counts, which is around 151 queries.
@articles = Article.includes(:author, :category, :comments)
SELECT * FROM articles;
SELECT * FROM authors WHERE id IN (...);
SELECT * FROM categories WHERE id IN (...);
SELECT * FROM comments WHERE article_id IN (...);
This drops the query count from around 151 down to 4, regardless of how many articles you have.
@articles = Article.preload(:author, :category, :comments)
Since we are not filtering by any of these associations, preload produces the exact same four queries as includes in this case, with the guarantee that it will never switch to a JOIN.
@articles = Article.eager_load(:author, :category, :comments)
SELECT articles.*, authors.*, categories.*, comments.*
FROM articles
LEFT OUTER JOIN authors ON authors.id = articles.author_id
LEFT OUTER JOIN categories ON categories.id = articles.category_id
LEFT OUTER JOIN comments ON comments.article_id = articles.id;
This works, but because comments is a has_many association, this JOIN multiplies the article, author, and category columns across every comment row, which can quickly become wasteful for articles with a lot of comments.
For this scenario, preload or plain includes is the better choice. Both keep the query count low without duplicating data across multiple comment rows. eager_load would only make sense here if you needed to filter articles by something like a comment's content or an author's active status in the same query.
When should I use includes? Use includes as your default eager loading method for most situations. It handles simple display cases with two queries and automatically switches to a JOIN if you filter by the association, which removes the need to think about it in many everyday cases.
Does preload always execute two queries? Yes, for each association you preload, Rails runs a separate query. preload never merges these into a JOIN, which is exactly why you cannot filter on the association's columns when using it.
Why does eager_load use LEFT OUTER JOIN? eager_load uses a LEFT OUTER JOIN so that records without an associated row are still included in the result set, with null values in place of the missing association's columns. This makes it possible to filter or sort using the association's data within a single query.
Is includes slower than preload? Not inherently. When includes does not need to switch to a JOIN, it runs the exact same separate queries that preload does. The performance difference only shows up if your query chain forces includes into JOIN mode when you did not intend it to.
Can includes eliminate every N+1 query? It eliminates N+1 queries for the associations you explicitly include. It will not help with associations you forget to eager load, or with nested associations accessed several levels deep, unless you eager load those levels too.
Which method should I use in Rails 8? The core behavior of includes, preload, and eager_load has not fundamentally changed in Rails 8. The same guidance applies: default to includes, reach for preload when you want guaranteed separate queries, and use eager_load when you need guaranteed JOIN behavior for filtering or sorting.
includes, preload, and eager_load all exist to solve the same core problem, avoiding N+1 queries by loading associations ahead of time instead of one record at a time. The difference comes down to how each one gets there. includes adapts based on your query chain, preload always runs separate queries, and eager_load always forces a single JOIN.
There is no single correct answer for every situation. The best approach is to understand what each method actually does under the hood, look at the SQL your code generates, and choose based on whether you need to filter by an association or simply display it. When in doubt, turn on your Rails query logs, install the Bullet gem, and benchmark before and after. That habit alone will save you from most Rails performance headaches, long before they show up in production.