Rails Performance Optimization: Fixing N+1 Queries with includes, preload, and eager_load

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 preload when you want guaranteed separate queries
  • Use eager_load when you need JOIN-based filtering

A few minutes spent analyzing your SQL queries can save hours of performance troubleshooting later.

Leave a Reply