Catch N+1 Queries in Rails Request Specs with 11 Lines of Ruby

Published: (February 1, 2026 at 12:23 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

Cover image for Catch N+1 Queries in Rails Request Specs with 11 Lines of Ruby

N+1 queries are one of the most common performance pitfalls in Rails applications. Tools like Bullet and Prosopite can help detect them, but they add dependencies and configuration overhead. Sometimes you just want a lightweight, transparent way to assert on query counts directly in your request specs.

Here’s how to do it with nothing more than what Rails already gives you.

The Technique

Rails instruments every SQL query through ActiveSupport::Notifications under the sql.active_record event. We can subscribe to that event for the duration of a block and collect every query that fires.

Create a file at spec/support/query_counter.rb:

# spec/support/query_counter.rb
module QueryCounter
  def capture_queries(&block)
    queries = []
    callback = lambda do |_name, _start, _finish, _id, payload|
      queries << payload[:sql] unless payload[:sql].match?(/SCHEMA|BEGIN|COMMIT|SAVEPOINT|RELEASE/)
    end

    ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)

    queries
  end
end

RSpec.configure do |config|
  config.include QueryCounter, type: :request
end

The key method here is ActiveSupport::Notifications.subscribed, which temporarily subscribes a callback for the duration of the block and automatically unsubscribes afterward. The regex filter excludes noise — schema introspection and transaction control statements — so you’re left with only the queries your application code actually triggers.

Using It in a Spec

Suppose you have an endpoint that returns a post with its comments. A naive implementation might load the post, then issue a separate query for each comment — the classic N+1. Here’s how you’d catch that:

# spec/requests/posts_spec.rb
RSpec.describe "Posts API", type: :request do
  let!(:posts) { create_list(:post, 5, :with_comments) }
  let(:target_id) { posts.last.id }

  it "avoids N+1 queries when fetching post details" do
    queries = capture_queries { get "/api/posts/#{target_id}" }

    comment_queries = queries.count { |q| q.include?("comments") }
    expect(comment_queries).to eq(1)
  end
end

The test makes the request inside capture_queries, then filters the collected SQL strings to count how many hit the comments table. If someone removes an includes call or introduces a lazy load, this test fails.

Why This Works Well

  • It’s explicit. Instead of relying on a background tool to warn you, you’re making a direct assertion. The intent is clear to anyone reading the spec.

  • It tests the full stack. Because this runs inside a request spec, you’re exercising the actual controller, serializer, and any eager loading — not a unit test on an isolated query.

  • It gives you the raw SQL. The queries array contains the actual SQL strings, so when a test fails you can inspect exactly what fired. This makes debugging straightforward:

    queries = capture_queries { get "/api/posts/#{target_id}" }
    puts queries # see every query that ran
  • No dependencies. This uses ActiveSupport::Notifications, which is part of Rails. There’s nothing to install, configure, or keep up to date.

Limitations

This approach is intentionally minimal. It doesn’t automatically detect N+1s — you have to know what to assert on. It doesn’t provide RSpec matchers like expect { }.to execute_queries(at_most: 3). And it won’t alert you to regressions unless you’ve written a test for that specific endpoint.

For projects that need automatic detection across every request, tools like Prosopite or Bullet are better suited. But for targeted assertions on critical endpoints, 12 lines of Ruby and a clear test go a long way.

Tips for Adoption

  • Start with your slowest endpoints. Add capture_queries to the request specs for endpoints you know are performance‑sensitive.
  • Assert on specific tables. Counting all queries is brittle — a schema change might add or remove a join. Filtering by table name (like the comments example above) makes tests more resilient.
  • Use it during development. Wrap a controller action in capture_queries in a scratch test to see what’s actually happening before you optimise.

The best performance tools are the ones you’ll actually use. Sometimes that’s a full‑featured gem. Sometimes it’s 12 lines in spec/support.


Thanks for reading! I’m currently exploring solutions for my own problems. I recently created shaicli.dev and frost. Please check them out—I’d love to know what you think!

Back to Blog

Related posts

Read more »