Introduction
If you’ve worked with Rails for some time, you’ve probably seen performance issues that did not make much sense at first.
One of the usual suspects is the N+1 query problem. It is easy to miss, and it can quietly slow things down as your data grows.
What is the N+1 Problem?
In simple terms:
- You load a list of records (1 query)
- Then Rails runs one query per record to fetch associated data
A quick example:
users = User.all
users.each do |user|
user.posts.each do |post|
puts post.title
end
end
Looks harmless, but under the hood:
- 1 query → users
- N queries → posts (one per user)
So if you have 100 users, that’s 101 queries.
Why this becomes a problem
At small scale, you won’t notice anything.
But once your data grows:
- pages start feeling slow
- APIs take longer to respond
- database load increases for no good reason
This is one of those issues that doesn’t break your app—but slowly makes it worse.
Fixing it: Preloading
Rails gives you a few ways to deal with this. The main ones are:
includespreloadeager_load
They all solve N+1, but each one behaves a little differently.
includes (what I reach for first)
users = User.includes(:posts)
For most cases, this is enough.
Rails will either:
- run separate queries, or
- switch to a JOIN if needed
Usually, you do not have to think too hard here.
One thing to be aware of:
User.includes(:posts).where(posts: { published: true })
This turns into a JOIN behind the scenes, so it is easy to miss if you are not watching the SQL.
preload (when I want predictable behavior)
users = User.preload(:posts)
This always runs separate queries:
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (...);
I tend to use this when:
- the dataset is large
- I don’t want the complexity of JOINs
It is a bit boring, but it is predictable.
eager_load (when I know I need a JOIN)
User.eager_load(:posts).where(posts: { published: true })
This forces a LEFT OUTER JOIN.
Useful when:
- you’re filtering on associations
- or sorting using associated columns
Just be careful, because JOINs can get heavy pretty fast.
How I usually decide
I usually keep it simple:
- start with
includes - switch to
preloadif JOINs feel heavy - use
eager_loadwhen querying associations
That is usually enough for me.
A couple of gotchas
Hidden JOINs
User.includes(:posts).where(posts: { published: true })
This behaves like eager_load.
If you are debugging performance, that part matters.
Loading data you don’t use
User.includes(:posts)
If you never touch posts, that is just extra memory for nothing.
Duplicate records
With JOINs, you might see duplicates:
User.eager_load(:posts).distinct
When to use each one
| Method | Best for | Trade-off |
|---|---|---|
includes |
Default choice for most association loading | Can switch between separate queries and JOINs |
preload |
Predictable separate queries | Less flexible when filtering on associated tables |
eager_load |
Filtering or sorting on associated columns | Can create heavier JOIN queries |
Related reading
- Rails Active Record Querying Guide
- Optimizing Slack API usage in Rails apps with caching
- Setting up CI for a multi-database Rails app
Real-world usage
Most of the time, this shows up in:
Views
@users = User.includes(:posts)
APIs / serializers
users = User.includes(:posts)
render json: users
Queries
User.left_joins(:posts).where(posts: { id: nil })
How to catch it
- Rails logs are usually enough
- If you want something automated, use the
bulletgem
Final thoughts
For me, the biggest shift was this:
Instead of thinking in Rails methods, start thinking in queries.
- How many queries is this generating?
- What SQL is actually being run?
- How does this behave with more data?
Once you start asking those questions, N+1 becomes much easier to spot and fix.