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
Enter fullscreen mode Exit fullscreen mode
We fetch all posts:
@posts = Post.all
Enter fullscreen mode Exit fullscreen mode
And display the author’s name:
<% @posts.each do |post| %>
<%= post.title %>
<%= post.user.name %>
<% end %>
Enter fullscreen mode Exit fullscreen mode
Queries Generated
Rails first loads all posts:
SELECT "posts".*
FROM "posts";
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
Rails executes:
SELECT "posts".*
FROM "posts";
Enter fullscreen mode Exit fullscreen mode
Then:
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (1, 2, 3, 4, 5);
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
Generated Queries
SELECT "posts".*
FROM "posts";
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (1,2,3,4,5);
Enter fullscreen mode Exit fullscreen mode
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 })
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
Queries Generated
SELECT "posts".*
FROM "posts";
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (1,2,3,4,5);
Enter fullscreen mode Exit fullscreen mode
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 })
Enter fullscreen mode Exit fullscreen mode
Error:
missing FROM-clause entry for table "users"
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode
Everything is fetched in a single query.
Filtering on Associated Tables
Post.eager_load(:user)
.where(users: { active: true })
Enter fullscreen mode Exit fullscreen mode
Generated SQL:
SELECT
"posts".*,
"users".*
FROM "posts"
LEFT OUTER JOIN "users"
ON "users"."id" = "posts"."user_id"
WHERE "users"."active" = TRUE;
Enter fullscreen mode Exit fullscreen mode
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 YesWhich One Should You Use?
*Use includes
*
Post.includes(:user)
Default choice for most cases.
Use preload
Post.preload(:user)
Enter fullscreen mode Exit fullscreen mode
When you explicitly want separate queries and no JOIN.
Use eager_load
Post.eager_load(:user)
Enter fullscreen mode Exit fullscreen mode
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'
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기