The Friday Deploy
The plain English newsletter for people who run the business, not the tech stack. No jargon. Every Friday.
N+1 Queries Are Killing Your Rails App: How to Find and Fix Them
Your Rails application feels slow. Pages that should load instantly are taking seconds. Your database server is struggling under load. You check your logs and see hundreds of nearly identical queries executing for a single page request.
You’ve got an N+1 query problem.
This is one of the most common performance issues in Rails applications. It’s easy to introduce and surprisingly hard to spot until it’s already a problem in production.
What Are N+1 Queries?
The N+1 query problem occurs when your application makes one query to fetch a list of records, then makes an additional query for each of those records to fetch associated data.
Here’s a simple example. You want to display a list of blog posts with their author names:
# Controller
@posts = Post.all
# View
<% @posts.each do |post| %>
<h2><%= post.title %></h2>
<p>By <%= post.user.name %></p>
<% end %>
This innocent-looking code generates the following queries:
SELECT * FROM posts -- 1 query
SELECT * FROM users WHERE id = 1 -- Query for first post's author
SELECT * FROM users WHERE id = 2 -- Query for second post's author
SELECT * FROM users WHERE id = 3 -- Query for third post's author
...
If you have 10 posts, that’s 11 queries. With 100 posts, that’s 101 queries. With 10,000 posts? You’re looking at 10,001 database round trips.
This is the “N+1” pattern: 1 query to fetch the posts, plus N queries (one for each post) to fetch the associated users.
Why This Kills Performance
Database queries aren’t free. Each query involves:
- Network round-trip between your application and database
- Query parsing and planning by the database
- Data retrieval and transfer
- Object instantiation in Rails
A single combined query is often faster than hundreds of small queries, even if the total amount of data retrieved is the same. The overhead compounds quickly.
On a real production application with 1,000 posts, this pattern could turn a page that should load in 50ms into one that takes 3-5 seconds. During that time:
- Your database connections are tied up
- Your application servers are waiting
- Your users are staring at loading spinners
- Your database might be hitting connection limits
How N+1 Queries Sneak Into Your Code
These problems are insidious because they often don’t appear during development. When you’re testing with 5 posts in your local database, 6 queries feels fine. The problem only becomes obvious in production with real data volumes.
Common scenarios where N+1 queries appear:
Displaying collections with associations:
# Posts with author names
@posts.each do |post|
puts post.user.name # N+1: separate query for each post's user
end
# Orders with customer details
@orders.each do |order|
puts order.customer.email # N+1: separate query per order
end
# Products with category names
@products.each do |product|
puts product.category.name # N+1: separate query per product
end
Nested associations:
# Posts with comments and comment authors
@posts.each do |post|
post.comments.each do |comment|
puts comment.user.name # N+1 on posts, then N+1 on comments
end
end
# Orders with line items and products
@orders.each do |order|
order.line_items.each do |item|
puts item.product.name # Multiple levels of N+1
end
end
Conditional logic that accesses associations:
<% if post.user.admin? %>
<!-- Show admin badge -->
<% end %>
Methods that traverse associations:
class Post < ApplicationRecord
belongs_to :user
def author_display_name
user.name || user.email
end
end
# This triggers an N+1 when called on multiple posts
@posts.each { |post| puts post.author_display_name }
Finding N+1 Queries in Your Application
The Bullet Gem
If you’re not using Bullet in development, you’re making this harder than it needs to be. Add it to your Gemfile:
group :development do
gem 'bullet'
end
Configure it in config/environments/development.rb:
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.console = true
Bullet.rails_logger = true
end
Now Bullet will alert you whenever it detects N+1 queries, showing exactly which associations should be eager-loaded. It’s a good example of why exhaustive, dumb tools sometimes beat AI at this kind of systematic work where you need to catch every instance.
Production Monitoring
Tools like Scout APM, New Relic, and Skylight highlight N+1 queries in production, showing you which endpoints are affected and how frequently they occur.
This helps prioritise fixes. Not all N+1 queries matter equally. One that executes 1,000 times per day on a page with 100 records deserves attention. One that executes twice per week with 3 records probably doesn’t.
Fixing N+1 Queries with Eager Loading
The solution is eager loading using Rails’ includes method.
Basic Eager Loading
Instead of this:
@posts = Post.all
Do this:
@posts = Post.includes(:user).all
Now Rails generates just two queries (when you access the association):
SELECT * FROM posts
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, ...)
The second query fires when you first touch post.user, but it loads all users at once rather than one at a time.
Note: Depending on the query, includes may issue separate queries or a LEFT OUTER JOIN. Rails chooses the strategy automatically based on your filtering and ordering conditions.
Multiple Associations
You can eager-load multiple associations:
@posts = Post.includes(:user, :category, :tags).all
Nested Associations
For nested associations, use a hash:
# Posts with users and their accounts
@posts = Post.includes(user: :account).all
# Posts with comments and comment authors
@posts = Post.includes(comments: :user).all
# Multiple nested associations
@posts = Post.includes(
:user,
comments: [:user, :reactions]
).all
Rails also provides preload (always uses separate queries) and eager_load (always uses LEFT OUTER JOIN) for cases where you want explicit control over the loading strategy.
When Includes Isn’t Enough
If you’re filtering by associated data but not displaying it, use joins instead:
# Find posts by users in Australia
@posts = Post.joins(:user).where(users: { country: 'Australia' })
You can combine both:
# Filter by country, but also load users for display
@posts = Post
.joins(:user)
.where(users: { country: 'Australia' })
.includes(:user, :comments)
Batch Processing for Large Datasets
When working with thousands of records, even eager loading can consume excessive memory. Use batch processing:
Post.includes(:user).find_each(batch_size: 100) do |post|
# Process each post
# Records are loaded in batches of 100
end
This loads records in manageable chunks, keeping memory usage reasonable. Note that Rails will preload associations once per batch, not once for the entire dataset. This is expected behaviour and ensures memory stays under control.
Counter Caches
For counts, use counter caches:
# Migration
add_column :posts, :comments_count, :integer, default: 0
# Model
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
Now post.comments_count reads from a cached column. Counter caches update through ActiveRecord callbacks, so direct SQL updates won’t adjust the counts.
Preventing N+1 Queries
Enable Bullet in development and pay attention to its warnings. Make fixing N+1 queries part of your code review process.
If an association is almost always needed, create a named scope:
class Post < ApplicationRecord
belongs_to :user
belongs_to :category
# Preloads commonly needed associations to avoid N+1 queries
scope :with_associations, -> { includes(:user, :category) }
end
# Then use consistently in controllers
@posts = Post.with_associations.all
This encapsulates the eager loading logic in one place and makes your code self-documenting. Avoid default_scope for this purpose as it can cause unexpected behaviour across your codebase.
Set up production monitoring that alerts on slow endpoints so N+1 queries that slip through get caught quickly.
When N+1 Might Not Be a Problem
N+1 queries aren’t always worth fixing.
Small datasets: If you’re loading 5 records, 6 queries is fine. Don’t optimise prematurely.
Rarely accessed pages: If an admin page with N+1 queries is viewed twice per month, your time is better spent elsewhere.
When caching covers it: If the view is cached, the queries don’t run on subsequent requests.
Trade-offs: Sometimes 100 simple indexed queries can be faster than one complex multi-table join. Profile to confirm.
The Real-World Impact
A client came to us with an e-commerce platform where the product listing page was taking 4-5 seconds to load. The database logs showed over 2,000 queries per page load.
The fix? Adding strategic includes statements to eager-load categories, images, and inventory data reduced query count to fewer than 20. Page load time dropped to under 200ms.
That’s 20x faster. Your users don’t know or care about N+1 queries. They just know your application feels slow. Fix the N+1 queries, and suddenly your application feels fast again.
Read more Technical Deep Dives
ActiveRecord, Code Quality, Database Optimisation, Eager Loading, N+1 Queries, Performance, Performance Monitoring, Rails, Ruby on Rails, Technical Debt
Previous NextThe Friday Deploy
The plain English newsletter for people who run the business, not the tech stack. No jargon. Every Friday.