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:

  • includes
  • preload
  • eager_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 preload if JOINs feel heavy
  • use eager_load when 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

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 bullet gem

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.