DEV Community

Yashika Vijayvargiya
Yashika Vijayvargiya

Posted on

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
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 Yes

Which 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 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.

Top comments (0)