One of the first performance issues I learned to look for in Rails applications is the N+1 query problem.

It’s surprisingly easy to introduce. The code usually looks clean, everything works as expected, and during development you probably won’t notice anything wrong.

Then the application starts getting more data.

Pages become slower, API responses take longer, and the database suddenly starts doing a lot more work than it needs to.

More often than not, the culprit is an N+1 query.

What is an N+1 Query?

Imagine loading a list of users and then looping through each user’s posts.

users = User.all

users.each do |user|
  user.posts.each do |post|
    puts post.title
  end
end

At first glance, nothing looks unusual.

Under the hood, Rails executes:

  • One query to fetch the users.
  • One additional query for each user’s posts.

If there are 100 users, you’ve just executed 101 queries.

That’s where the name N+1 comes from.

Why It Matters

With ten users, you probably won’t notice.

With a few thousand users, the difference becomes obvious.

Instead of asking the database for everything it needs up front, the application keeps going back and asking for a little more data every time it loops.

Those extra round trips add up quickly.

The First Thing I Reach For

Most of the time, includes is all I need.

users = User.includes(:posts)

Rails will preload the associated records and avoid making another query every time user.posts is accessed.

For most applications, this is the simplest solution and the one I start with.

When includes Isn’t Enough

One thing that’s worth knowing is that includes doesn’t always behave the same way.

Consider this query:

User.includes(:posts).where(posts: { published: true })

Because the query filters on the associated table, Rails switches to using a SQL JOIN.

That isn’t necessarily a problem, but it’s something I like to keep in mind when I’m trying to understand why a query behaves differently than I expected.

When I Use preload

Sometimes I know I don’t want a join.

In those cases, I use preload.

users = User.preload(:posts)

This always executes two separate queries.

SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (...);

I tend to use it when I want predictable behavior or when I know a large join would be more expensive than loading the associations separately.

When I Use eager_load

If I know I need a join, I’ll use eager_load.

User.eager_load(:posts)
    .where(posts: { published: true })

This generates a LEFT OUTER JOIN.

It’s useful when filtering or sorting using associated tables, but I try not to reach for it unless I actually need that behavior.

A Few Things I Always Check

Over time, there are a few things I’ve learned to watch out for.

Hidden joins

User.includes(:posts).where(posts: { published: true })

Even though it uses includes, Rails generates a join behind the scenes.

Loading associations you never use

User.includes(:posts)

If the view or serializer never accesses posts, you’ve loaded extra data into memory for no benefit.

Duplicate rows

When working with joins, duplicates can appear.

User.eager_load(:posts).distinct

Using distinct is often enough to solve that.

How I Usually Decide

I try not to overthink it.

  • I start with includes.
  • If I want separate queries no matter what, I use preload.
  • If I’m filtering or sorting on an associated table, I use eager_load.

Most of the time, that’s enough.

How I Catch N+1 Queries

The Rails logs are usually the first place I look.

If I keep seeing the same query repeated over and over again, that’s normally a good sign that I’ve introduced an N+1 query somewhere.

For larger projects, I also like using the bullet gem. It’s a simple way to catch these issues during development before they make it into production.

Looking Back

When I first started learning Rails, I mostly thought about models and associations.

Now I spend a lot more time thinking about the SQL that’s actually being generated.

The Rails API makes working with associations incredibly easy, but it’s also easy to forget that every method call can turn into another database query.

These days, whenever I write or review code that loops over associations, I automatically ask myself one question:

How many queries is this going to execute?

More often than not, that question is enough to catch the problem before it reaches production.