Originally published on Hashnode:
https://railswithyashika.hashnode.dev/rails-performance-n-plus-one-queries
When working with associations in Rails, it's easy to accidentally introduce performance issues. One of the most common problems is the N+1 Query Problem.
In this article, we'll understand what N+1 queries are, how they impact performance, and the differences between includes, preload, and eager_load.
What is an N+1 Query?
Suppose we have the following models:
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
end
We fetch all posts:
@posts = Post.all
And display the author's name:
<% @posts.each do |post| %>
<%= post.title %>
<%= post.user.name %>
<% end %>
Queries Generated
Rails first loads all posts:
SELECT "posts".*
FROM "posts";
Then for each post:
- 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;
If there are 100 posts, Rails executes:
- 1 query for posts
- 100 queries for users
Total: 101 queries
This is called the N+1 query problem.
Why is it a Problem?
As the amount of data grows:
- Database load increases
- Response times become slower
- More memory and CPU are consumed
- Application scalability decreases A page that works fine with 10 records can become painfully slow with 1,000 records.
Fixing N+1 Queries with includes
The simplest solution is:
@posts = Post.includes(:user)
Rails executes:
SELECT "posts".*
FROM "posts";
Then:
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (1, 2, 3, 4, 5);
Only 2 queries are executed regardless of how many posts exist.
Understanding includes
Most Rails developers use includes, but many don't know how it actually works.
Example
Post.includes(:user)
Generated Queries
SELECT "posts".*
FROM "posts";
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (1,2,3,4,5);
Rails loads records using separate queries and associates them in memory.
When Rails Converts includes into JOIN
Consider:
Post.includes(:user)
.where(users: { active: true })
Now Rails generates:
SELECT
posts.id,
posts.title,
users.id,
users.name
FROM posts
LEFT OUTER JOIN users
ON users.id = posts.user_id
WHERE users.active = true;
Because the query references the users table, Rails automatically switches to a JOIN strategy.
Understanding preload
preload always loads associations using separate queries.
Example
Post.preload(:user)
Queries Generated
SELECT "posts".*
FROM "posts";
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (1,2,3,4,5);
Notice that the generated SQL is similar to includes.
Key Difference
Unlike includes, preload never converts into a JOIN.
This will fail:
Post.preload(:user)
.where(users: { active: true })
Error:
missing FROM-clause entry for table "users"
Since no JOIN is generated, Rails cannot reference columns from the users table.
When to Use preload
Use preload when:
- You know separate queries are preferred.
- You only want to avoid N+1 queries.
- You don't need conditions on associated tables.
Understanding eager_load
eager_load always uses a LEFT OUTER JOIN.
Example
Post.eager_load(:user)
Query Generated
SELECT
posts.id,
posts.title,
posts.user_id,
users.id,
users.name
FROM posts
LEFT OUTER JOIN users
ON users.id = posts.user_id;
Everything is fetched in a single query.
Filtering on Associated Tables
Post.eager_load(:user)
.where(users: { active: true })
Generated SQL:
SELECT
"posts".*,
"users".*
FROM "posts"
LEFT OUTER JOIN "users"
ON "users"."id" = "posts"."user_id"
WHERE "users"."active" = TRUE;
This works because the users table is already joined.
includes vs preload vs eager_load
| Method | Queries | Uses JOIN | Can Filter Associated Table |
|---|---|---|---|
| includes | Usually 2 | Sometimes | Yes |
| preload | 2 | No | No |
| eager_load | 1 | Always | Yes |
Which One Should You Use?
*Use includes
*
Post.includes(:user)
Default choice for most cases.
Use preload
Post.preload(:user)
When you explicitly want separate queries and no JOIN.
Use eager_load
Post.eager_load(:user)
When filtering, ordering, or searching on associated tables.
Detecting N+1 Queries
A few ways to identify N+1 problems:
Check Development Logs
Look for repeated queries being executed inside loops.
Use Bullet Gem
Add:
gem 'bullet'
Bullet will notify you whenever an N+1 query is detected.
Use Monitoring Tools
- Scout APM
- New Relic
- Datadog
These tools help identify slow database queries in production.
Conclusion
N+1 queries are one of the most common performance issues in Rails applications.
Understanding the differences between includes, preload, and eager_load can help you write more efficient database queries and build scalable applications.
As a rule of thumb:
- Start with
includes - Use
preloadwhen you want guaranteed separate queries - Use
eager_loadwhen you need JOIN-based filtering
A few minutes spent analyzing your SQL queries can save hours of performance troubleshooting later.