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:

classUser<ApplicationRecordhas_many:postsendclassPost<ApplicationRecordbelongs_to:userend
Enter fullscreen modeExit fullscreen mode

We fetch all posts:

@posts=Post.all
Enter fullscreen modeExit fullscreen mode

And display the author's name:

<%@posts.eachdo|post|%><%=post.title%><%=post.user.name%><%end%>
Enter fullscreen modeExit fullscreen mode

Queries Generated
Rails first loads all posts:

SELECT"posts".*FROM"posts";
Enter fullscreen modeExit fullscreen mode

Then for each post:

-SELECT"users".*FROM"users"WHERE"users"."id"=1LIMIT1;-SELECT"users".*FROM"users"WHERE"users"."id"=2LIMIT1;-SELECT"users".*FROM"users"WHERE"users"."id"=3LIMIT1;
Enter fullscreen modeExit 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 modeExit fullscreen mode

Rails executes:

SELECT"posts".*FROM"posts";
Enter fullscreen modeExit fullscreen mode

Then:

SELECT"users".*FROM"users"WHERE"users"."id"IN(1,2,3,4,5);
Enter fullscreen modeExit 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 modeExit fullscreen mode

Generated Queries

SELECT"posts".*FROM"posts";SELECT"users".*FROM"users"WHERE"users"."id"IN(1,2,3,4,5);
Enter fullscreen modeExit 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 modeExit fullscreen mode

Now Rails generates:

SELECTposts.id,posts.title,users.id,users.nameFROMpostsLEFTOUTERJOINusersONusers.id=posts.user_idWHEREusers.active=true;
Enter fullscreen modeExit 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 modeExit fullscreen mode

Queries Generated

SELECT"posts".*FROM"posts";SELECT"users".*FROM"users"WHERE"users"."id"IN(1,2,3,4,5);
Enter fullscreen modeExit 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 modeExit fullscreen mode

Error:

missing FROM-clause entry for table "users"
Enter fullscreen modeExit 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 modeExit fullscreen mode

Query Generated

SELECTposts.id,posts.title,posts.user_id,users.id,users.nameFROMpostsLEFTOUTERJOINusersONusers.id=posts.user_id;
Enter fullscreen modeExit fullscreen mode

Everything is fetched in a single query.

Filtering on Associated Tables

Post.eager_load(:user).where(users: {active: true})
Enter fullscreen modeExit fullscreen mode

Generated SQL:

SELECT"posts".*,"users".*FROM"posts"LEFTOUTERJOIN"users"ON"users"."id"="posts"."user_id"WHERE"users"."active"=TRUE;
Enter fullscreen modeExit fullscreen mode

This works because the users table is already joined.

includes vs preload vs eager_load

MethodQueriesUses JOINCan Filter Associated Table
includesUsually 2SometimesYes
preload2NoNo
eager_load1AlwaysYes

Which One Should You Use?

*Use includes
*

Post.includes(:user)

Default choice for most cases.

Use preload

Post.preload(:user)
Enter fullscreen modeExit fullscreen mode

When you explicitly want separate queries and no JOIN.

Use eager_load

Post.eager_load(:user)
Enter fullscreen modeExit 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 modeExit 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)