How to Detect and Eliminate N+1 Queries in Rails with Bullet

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

  1. What Actually Happens with N+1
  2. A Real Example
  3. Why You Don't Notice Until It's Too Late
  4. Enter Bullet
  5. Installing and Configuring Bullet
  6. Reading Bullet's Output
  7. Fixing It with includes
  8. includes vs preload vs eager_load
  9. Wiring Bullet into RSpec
  10. Bullet in CI
  11. What Bullet Won't Catch
  12. Manual Detection via Logs
  13. A Complex Real-World Chain
  14. Before and After: The Numbers
  15. 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:

  1. N+1 queries — the main event
  2. Unused eager loading — when you .includes() something you never actually use
  3. 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.alert and Bullet.add_footer. They inject HTML into responses, which breaks JSON. Use rails_logger and bullet_logger only.

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.