How to Detect and Eliminate N+1 Queries in Rails with Bullet
I've seen N+1 queries take down apps that were perfectly fine in staging. Not crashing — just silently bleeding. Response times creeping from 200ms to 4 seconds. Database CPU spiking. Engineers confused because "it worked on my machine."
It always starts the same way: someone writes innocent-looking view code, the local dev database has 12 records, nothing looks wrong. Then you ship it and your database starts running 800 queries per request.
This post is about catching that before it happens — and fixing it when it already did.
Table of Contents
- What Actually Happens with N+1
- A Real Example
- Why You Don't Notice Until It's Too Late
- Enter Bullet
- Installing and Configuring Bullet
- Reading Bullet's Output
- Fixing It with
includes includesvspreloadvseager_load- Wiring Bullet into RSpec
- Bullet in CI
- What Bullet Won't Catch
- Manual Detection via Logs
- A Complex Real-World Chain
- Before and After: The Numbers
- Checklist
What Actually Happens with N+1
The problem has a name that explains itself: you run 1 query to load a collection, then N more queries — one per record — to fetch associated data. So if you have 200 posts, you end up running 201 queries instead of 2.
The reason this happens is that ActiveRecord is lazy by default. It doesn't load associations until you actually access them. That's usually a good thing. But inside a loop, it means every iteration fires a new database round-trip.
1 query → SELECT * FROM posts
N queries → SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
SELECT * FROM users WHERE id = 3
... (one per post)
Simple math. Terrible consequences at scale.
A Real Example
Here's the classic setup. A Post that belongs to a User, with Comments:
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
end
The controller looks fine:
# app/controllers/posts_controller.rb
def index
@posts = Post.all
end
The view looks fine too:
<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<p>Written by: <%= post.user.name %></p>
<p>Comments: <%= post.comments.count %></p>
</div>
<% end %>
But here's what Rails is actually doing under the hood:
-- 1 query for the posts
SELECT "posts".* FROM "posts"
-- 1 query per post for the user
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
-- ... × N
-- 1 query per post to count comments
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 2
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 3
-- ... × N
200 posts = 401 queries. Should be 3.
Why You Don't Notice Until It's Too Late
In development your database has 10 records. Everything is local, latency is zero, Rails logs scroll by too fast to read. You load the page, it's fast, you move on.
Then in production:
- The database has 50,000 records
- Each query has 5–20ms of network latency on top of execution time
- Multiple users hit the endpoint simultaneously
- Your connection pool starts getting exhausted
- Response times go from 200ms to 4 seconds
- You check the APM and see
GET /posts → 892 queries → 4,312ms
And the worst part: it scales linearly with your data. The more your app grows, the worse it gets. It's not a bug that shows up suddenly — it's a slow bleed that gets worse every day.
Enter Bullet
Bullet is a gem that monitors your ActiveRecord queries and tells you — loudly — when an N+1 is happening. It integrates with your Rails logger, browser console, and test suite.
It catches three things:
- N+1 queries — the main event
- Unused eager loading — when you
.includes()something you never actually use - Counter cache opportunities — when you're repeatedly counting the same association
I've been using it for years. It's one of the first gems I add to any new Rails project.
Installing and Configuring Bullet
1. Gemfile
group :development, :test do
gem 'bullet'
end
bundle install
2. Development environment config
# config/environments/development.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # JS alert popup in the browser
Bullet.rails_logger = true # Writes to the Rails log
Bullet.add_footer = true # Shows query info at the bottom of each page
# I always enable these too:
Bullet.bullet_logger = true # Writes to log/bullet.log (cleaner to read)
Bullet.console = true # Logs to browser console
# Detect when you're eager loading something you don't need
Bullet.unused_eager_loading_enable = true
# Detect counter cache opportunities
Bullet.counter_cache_enable = true
# Show the exact file and line where the N+1 originates
Bullet.stacktrace_includes = ['app/']
end
end
API-only apps: Skip
Bullet.alertandBullet.add_footer. They inject HTML into responses, which breaks JSON. Userails_loggerandbullet_loggeronly.
3. The bullet.log file
This is the one I actually use day-to-day. Instead of digging through the full Rails log, just tail this:
tail -f log/bullet.log
Every N+1 gets written there with the association name, the model, and the exact line in your code where it's triggered.
Reading Bullet's Output
Load the /posts page with Bullet enabled and you'll see something like this in bullet.log:
USE eager loading detected
Post => [:user]
Add to your query: .includes(:user)
Call stack
app/views/posts/index.html.erb:4:in `block in _app_views_posts_index_html_erb___'
app/controllers/posts_controller.rb:5:in `index'
USE eager loading detected
Post => [:comments]
Add to your query: .includes(:comments)
Call stack
app/views/posts/index.html.erb:5:in `block in _app_views_posts_index_html_erb___'
app/controllers/posts_controller.rb:5:in `index'
I like how opinionated this output is. It doesn't just tell you there's a problem — it tells you exactly what to type to fix it. The call stack shows the view file and line number. You open the file, you see the issue, you fix it. Done.
Fixing It with includes
Based on what Bullet flagged, the fix is one line in the controller:
# Before
def index
@posts = Post.all
end
# After
def index
@posts = Post.includes(:user, :comments).all
end
Now Rails runs 3 queries regardless of how many posts you have:
-- 1: posts
SELECT "posts".* FROM "posts"
-- 2: all users in one shot
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)
-- 3: all comments in one shot
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3, ...)
Nested associations
If the view also accesses comment.user inside the comments loop — yes, that's another N+1 — you handle it by nesting the hash:
@posts = Post.includes(user: :profile, comments: :user).all
Rails figures out the full preloading chain from there.
includes vs preload vs eager_load
These three methods all eager-load associations, but they work differently. Knowing when to use each saves you from subtle bugs.
preload
Always runs separate queries — one per association:
Post.preload(:user, :comments)
SELECT "posts".* FROM "posts"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3)
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3)
Use it when you just want to load the data and don't need to filter on the association.
eager_load
Uses a single LEFT OUTER JOIN to pull everything in one query:
Post.eager_load(:user)
SELECT "posts"."id", "posts"."title", "users"."id", "users"."name"
FROM "posts"
LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"
Use it when you need to filter or sort by associated columns:
Post.eager_load(:user).where(users: { active: true }).order("users.name ASC")
One thing I've been burned by: eager_load on a has_many with a lot of records. The JOIN multiplies your result set — 1,000 posts × 50 comments = 50,000 rows coming back from the database. For has_many, stick with preload.
includes
This is the one I default to. It auto-detects which strategy to use:
# No filter on associated table → uses preload
Post.includes(:user)
# Filter references associated table → switches to eager_load automatically
Post.includes(:user).where(users: { active: true })
Unless you have a specific reason to reach for preload or eager_load directly, includes is the right call.
Quick reference
| Method | Strategy | Filter on association |
|---|---|---|
| preload | Separate queries | ❌ |
| eager_load | JOIN | ✅ |
| includes | Auto (both) | ✅ |
Wiring Bullet into RSpec
This is where Bullet goes from "useful dev tool" to "N+1s can't reach production." Set it up in your test suite and any spec that triggers an N+1 will fail automatically.
# spec/support/bullet.rb
if Bullet.enable?
RSpec.configure do |config|
config.before(:each) do
Bullet.start_request
end
config.after(:each) do
Bullet.perform_out_of_channel_notifications if Bullet.notification?
Bullet.end_request
end
end
end
Require it in rails_helper.rb:
require 'support/bullet'
And in the test environment, set raise to true:
# config/environments/test.rb
config.after_initialize do
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true # This is the key line
Bullet.unused_eager_loading_enable = true
end
With Bullet.raise = true, any N+1 detected during a test raises Bullet::Notification::UnoptimizedQueryError and fails the spec with the exact fix:
Bullet::Notification::UnoptimizedQueryError:
USE eager loading detected
Post => [:user]
Add to your query: .includes(:user)
Here's what a spec that enforces this looks like:
RSpec.describe PostsController, type: :controller do
describe "GET #index" do
it "loads posts without N+1 queries" do
create_list(:post, 10, :with_user_and_comments)
# Bullet raises here if N+1 is present
get :index
expect(response).to have_http_status(:ok)
end
end
end
Bullet in CI
Once Bullet.raise = true is in your test environment, CI just works. No extra configuration. Any PR that introduces an N+1 fails the rspec step, blocks the merge, and points directly at the line causing it.
For GitHub Actions:
- name: Run RSpec
run: bundle exec rspec
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost/myapp_test
Nothing special needed. Bullet hooks into ActiveRecord at the query level and does its thing transparently.
What Bullet Won't Catch
Bullet is good, but it has real blind spots I've hit in production. Worth knowing about:
Serializers
If you're using ActiveModel::Serializers or similar, N+1s happen inside the serializer after the controller already ran. Bullet often misses these.
class PostSerializer < ActiveModel::Serializer
attributes :id, :title
belongs_to :user # N+1 if posts weren't preloaded in the controller
end
Fix: preload everything you need before it reaches the serializer.
Background jobs
Bullet only monitors web requests. A Sidekiq job that loops over records and hits associations for each one won't be caught:
class ProcessPostsJob < ApplicationJob
def perform
Post.all.each do |post|
notify(post.user) # N+1 — Bullet won't see this
end
end
end
Fix: use .includes() in jobs just like you would in controllers. And write a test that calls the job directly — Bullet will catch it in test context.
Polymorphic associations
Bullet is inconsistent with polymorphic belongs_to. I've had cases where it missed the warning entirely. Always verify manually when polymorphics are involved.
has_one associations
Hit or miss depending on your Rails version. Don't rely on Bullet alone for has_one — check the logs.
Helpers and decorators
View helpers and Draper decorators can trigger N+1s that Bullet attributes to the wrong place or misses entirely. When in doubt, look at the raw query log.
Manual Detection via Logs
Even without Bullet, you can spot N+1s in your development log. The pattern is unmistakable — same query, sequential IDs, repeating over and over:
User Load (0.4ms) SELECT "users".* WHERE "users"."id" = $1 [["id", 1]]
User Load (0.3ms) SELECT "users".* WHERE "users"."id" = $1 [["id", 2]]
User Load (0.3ms) SELECT "users".* WHERE "users"."id" = $1 [["id", 3]]
Same query, different ID, repeated N times. That's your N+1.
explain for specific queries
Post.includes(:user, :comments).explain
For large tables, watch for Seq Scan in the output — it means no index is being used, which makes each individual query even more expensive.
rack-mini-profiler
I keep rack-mini-profiler installed in all my projects. It shows a query count badge on every page — when you see a number over 20, something's wrong.
# Gemfile
gem 'rack-mini-profiler', group: :development
gem 'flamegraph', group: :development
gem 'stackprof', group: :development
A Complex Real-World Chain
Here's a pattern I've encountered more than once in real apps: an order management view with multiple levels of nested associations.
The models
class Order < ApplicationRecord
belongs_to :customer
has_many :order_items
end
class OrderItem < ApplicationRecord
belongs_to :order
belongs_to :product
end
class Product < ApplicationRecord
belongs_to :category
end
class Customer < ApplicationRecord
belongs_to :address
end
The naive controller
def index
@orders = Order.all.limit(50)
end
The view
<% @orders.each do |order| %>
<tr>
<td><%= order.customer.name %></td>
<td><%= order.customer.address.city %></td>
<% order.order_items.each do |item| %>
<td><%= item.product.name %></td>
<td><%= item.product.category.name %></td>
<% end %>
</tr>
<% end %>
With 50 orders and 5 items each, the query count:
- 1 for orders
- 50 for customers
- 50 for addresses
- 250 for products
- 250 for categories
Total: 601 queries. For 50 rows.
The fix
def index
@orders = Order
.includes(customer: :address, order_items: { product: :category })
.limit(50)
end
6 queries. One per model in the chain.
Bullet would fire all five warnings at once, each pointing to the exact line in the view and the exact .includes() call to add. In practice I use that output to build the includes hash piece by piece until all warnings are gone.
Before and After: The Numbers
From a real staging environment with 500 orders:
| | Queries | DB Time | Response Time | |---|---|---|---| | Before | 2,501 | 4,820ms | 6,340ms | | After | 6 | 48ms | 210ms | | Delta | −99.8% | 100× faster | 30× faster |
These numbers aren't unusual. The response time improvement comes from multiple places at once: fewer queries, less connection overhead, less memory pressure from duplicate ActiveRecord objects, and less GC time from instantiating thousands of redundant model instances.
Checklist
I use this during code review for any PR that touches controllers or views:
[ ] Bullet installed and configured in development and test
[ ] Bullet.raise = true in test environment
[ ] log/bullet.log clean after manual QA pass
[ ] All associations accessed in views/serializers are included in the query
[ ] Nested associations use the hash syntax: includes(parent: :child)
[ ] Background jobs preload associations manually
[ ] New feature has a controller or request spec that will catch future regressions
[ ] rack-mini-profiler checked for pages with query counts over 20
[ ] explain run on any query touching tables > 50k rows
Wrapping Up
N+1 queries are boring to fix and devastating to ignore. The good news is that with Bullet in your test suite, they're essentially impossible to ship accidentally — any new N+1 fails CI before it ever gets reviewed.
The workflow I've settled into: Bullet in development to catch things while building, Bullet.raise = true in tests to block regressions, and rack-mini-profiler for a visual sanity check on anything that feels slow.
Your database will thank you.