n_plus_one_control: testing for N+1 queries by scaling the data
RSpec and Minitest matchers to prevent N+1 queries problem
At a glance
- What is it?
- Most tools that catch N+1 queries look at a single run and try to count the queries. This gem takes a different approach: it runs the code under test several times with different amounts of data and checks that the number of queries stays the same, which is the property you actually care about.
- Who is it for?
- n_plus_one_control fits a team that has been bitten by an N+1 query in production and wants a test that fails the moment it comes back, and it fits anybody who finds the exact-count expectations of the other tools brittle. It does not fit a project that wants a full report of every N+1 in the suite on one run, because that is what the detector libraries are for.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 91 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem, and the approach nobody else took
The gem's one-line description is RSpec and Minitest matchers to prevent the N+1 queries problem, and then it asks a question that is worth pausing on: why yet another gem to assert database queries?
The answer is about what you assert. Other libraries, and two are named, work by having you specify an exact expectation, so you write that a block performs two queries and the test checks exactly that. The readme's objection is that such expectations are hard to maintain, because there is a good chance of adding more queries that have nothing to do with the system under test.
That objection is correct, and it is the reason this kind of test rots. You write the expectation when the code does two queries. Later somebody adds a query for something unrelated, a cache read that touches the database, a soft delete scope, and the test fails. So somebody relaxes the expectation to three. And then the fourth query is an N+1 that nobody notices, because the test now expects three and the code does three.
The approach here is different, and it is a good one. The matcher evaluates the code under consideration several times with different scale factors, and checks that the number of queries behaves as expected, that is, that it stays constant instead of growing with the data.
So the assertion is not about a number. It is about a shape. If the code performs two queries for one row and two queries for a hundred rows, it is constant and the test passes. If it performs one query for one row and a hundred for a hundred rows, it is linear and the test fails.
And the readme draws the distinction that matters: this is for performance testing, not for feature testing. You are not asking whether the code returns the right rows. You are asking whether the number of database round trips is a function of the size of the data or not.
The project is MIT licensed, written in Ruby, and built by a consultancy that makes developer tools. The last push was in July 2026, and the release history is a version seven from 2023 after a long series of six point releases.
The RSpec matcher, and the two rules that will bite you
The usage section for RSpec is short, and the shape of it is worth understanding, because the two rules the readme gives are both about how the test framework's own machinery interacts with a matcher that runs your code several times.
You require the matcher in the spec helper, and then you wrap the example in a context tagged with a marker, which is what tells the library that the example uses scale factors. Inside that context you define a population callback whose job is to generate the data, and it takes one argument, the scale factor. Then the expectation is that the block performs a constant number of queries.
context "N+1", :n_plus_one do
populate { |n| create_list(:post, n) }
specify do
expect { get :index }.to perform_constant_number_of_queries
end
endThe first rule is about memoised values. The readme says not to use them within the expectation block, and it gives a bad example and a good one. The bad example puts the subject in a memoised block, so the subject is evaluated once, when the example first needs it, and then the matcher runs the expectation against the same object rather than running the code again. The good example calls the action inside the block, so each run is a fresh run.
The second rule is the subtler one and it is about the population callback. If a record is created in a lazily memoised helper, then the population callback that creates the comments refers to that record. On the first run the record exists. On the next run, the population callback runs again and the memoised record is still the one from the first run, which the test framework has now removed from the database as part of cleaning up the previous run. So the comment is created against a record that is not there, and the behaviour under test is not what you think it is.
The fix is to use the eager form of the helper, so the record is created before the population runs, and the readme shows that too.
let!(:page) { create(:page) }So the two rules are the same rule seen from two sides. Anything the matcher touches has to be re-creatable on every run, because the matcher runs the population and the expectation repeatedly, and the test framework's cleanup between runs will pull the ground out from under a lazily created record.
The modifiers, and the alternative to populating data
The matcher takes a set of modifiers, and they cover the cases where the default is not what you want.
You can filter which queries count with a regular expression, and the default is that only reads are considered. That default is the right one for this kind of test, since the problem is reads that happen once per row, but a project that cares about writes can say so.
expect { get :index }.to perform_constant_number_of_queries.matching(/INSERT/)You can supply your own scale factors, which is what you want when the default range does not cover the sizes you care about, and you can assert an exact number of queries when you do want one, which makes the matcher a generalisation of the tools it criticises rather than only an alternative.
expect { get :index }.to perform_constant_number_of_queries.with_scale_factors(10, 100)expect { get :index }.to perform_constant_number_of_queries.exactly(1)Then there is a case that comes up constantly in real applications, and the readme has a dedicated subsection for it. What if the action takes a parameter that changes how many records come back, so that growing the data is not the right way to grow the work?
Then you do not need to create more records. You change the parameter between runs, and the matcher gives you the current scale factor so the request can use it.
context "N+1", :n_plus_one do
before { create_list :post, 3 }
specify do
expect { get :index, params: {per_page: current_scale} }.to perform_constant_number_of_queries
end
endThat is a genuinely useful idea. It means the matcher can check a paginated index without the test suite creating thousands of records, and the cost of the check stays low.
There is also a way to put other expectations inside the block, so the same run checks the query count and the correctness of the result at once.
context "N+1", :n_plus_one do
specify do
expect do
expect(my_query).to eq(actuall_results)
end.to perform_constant_number_of_queries
end
endAnd there is a second matcher for the other shape of the problem: a linear number of queries with a given slope, which is how you test something that is supposed to be linear, which is what the readme's example does with a find-each loop over the posts reading the name of the author.
context "when has linear query", :n_plus_one do
populate { |n| create_list(:post, n) }
specify do
expect { Post.find_each { |p| p.user.name } }
.to perform_linear_number_of_queries(slope: 1)
end
endThat one is not a bug detector so much as a characterisation test. It says this operation is expected to cost one query per row, and if somebody changes it to two per row the test tells them, which is usually what you want rather than a failure.
Minitest, and the two frameworks side by side
The Minitest support mirrors the RSpec support, and the mapping is instructive because it shows the same idea expressed in two different idioms.
You require the Minitest file in the test helper, and then the assertion takes the population as a keyword argument and the code under test as a block.
def test_no_n_plus_one_error
populate = ->(n) { create_list(:post, n) }
assert_perform_constant_number_of_queries(populate: populate) do
get :index
end
endThe RSpec version uses a tagged context and a callback block; the Minitest version uses a plain lambda passed as an argument. The second is arguably closer to the original idea, because the scale factor is an argument of the assertion rather than an ambient piece of context, and you can see at the call site exactly what the population is.
The rest follows the same mapping. There is a linear assertion with the slope and the population. There is a way to give custom scale factors and a way to filter by pattern, and the filter is a regular expression matched against the queries.
assert_perform_constant_number_of_queries(
populate: populate,
scale_factors: [2, 5, 10]
) do
get :index
endassert_perform_constant_number_of_queries(
populate: populate,
matching: /INSERT/
) do
do_some_havey_stuff
endAnd the exact count is the first positional argument rather than a modifier, which is a difference in style that reflects the two frameworks: a Ruby method argument reads better than a chained modifier in a Minitest assertion, and a chained modifier reads better in a spec.
Two conveniences are worth noting. The expected count can be given as the first argument for the constant assertion, and there is a filter that can be set through an environment variable, so you can narrow a whole test run to one table without editing the tests.
NPLUSONE_FILTER = users bundle exec rake testThat is a small feature with a real use: when you are chasing one query in a large suite, being able to say which table you are looking at from the command line is the difference between a five minute investigation and an hour.
The population can also be defined as a method on the test class rather than a lambda, which is the more natural shape in Minitest because the class instance is already there.
def populate(n)
create_list(:post, n)
endAnd the current scale factor works in Minitest too, so the paginated example from the RSpec section is available in both.
How it compares with a detector, and the repository
The readme asks why not just use the well known detector library, and the answer is the most important thing in the document for anybody choosing a tool.
It is possible to use that library in tests, and there is a link to a write-up about it, but it is not a silver bullet: there can be both false positives and true negatives. The author then says where the gem came from: after finding that he could not verify yet another N+1 problem with a test.
That is a fair characterisation of the difference between the two approaches. A detector inspects one run of the code and reasons about the shape of the queries it saw. A scale-based matcher runs the code at several sizes and compares. The detector can be wrong in both directions: it can report an N+1 that is not one, because a query in a loop over a fixed small set is harmless, and it can miss one that is there, because the loop was not exercised. The scale-based matcher has neither problem, because a constant count is a constant count whatever the cause.
What the scale-based matcher gives up is the completeness of the report. It only tells you about the code paths you wrote a test for, while a detector can find the N+1 you did not think to test. The two are complementary, and the readme's own framing, that this is for performance testing rather than feature testing, says so.
The repository layout is that of a well-maintained Ruby gem. There is the library directory, a spec directory, a second directory of tests, a gemspec, a rake file, a gemfile directory for testing against several dependency versions, a release configuration, a document about the release process, an author list, a linter configuration with its own directory, and a separate configuration for the markdown. There is also a directory with a binary in it, which is the kind of thing you see in a gem that ships a command line helper alongside the library.
The project also has a badge for the linter on markdown files, which tells you that the documentation is checked by the same tooling as the code, and a top-level file for the test framework configuration, which is how a gem makes its own suite use the same conventions it recommends.
The links at the end of the readme are the ones to follow if you are deciding: an article on squashing N+1 queries early with these matchers, and another on fighting what the readme calls the hydra of N+1 queries, which is the article about the detector library. Reading both in that order gives you the argument for the approach in the author's own words.
Editorial conclusion
n_plus_one_control fits a team that has been bitten by an N+1 query in production and wants a test that fails the moment it comes back, and it fits anybody who finds the exact-count expectations of the other tools brittle. It does not fit a project that wants a full report of every N+1 in the suite on one run, because that is what the detector libraries are for. Verify first that the population callback you write is safe to run several times, because the matcher will run it once per scale factor, and that the values you memoise are created before the population rather than inside it, since the readme is explicit that a memoised record can be removed from the database and still present in the test.
Frequently asked questions
How does the n_plus_one_control gem detect N+1 queries?
By running the code under test several times with different scale factors and checking that the number of database queries stays constant rather than growing with the data. The assertion is about the shape of the count rather than about an exact number, which is what makes it stable when unrelated queries are added.
How do I use the n_plus_one_control matcher with RSpec?
Require the RSpec file in the spec helper, wrap the example in a context tagged with the marker, define a population callback that takes the scale factor and creates the data, and expect the block to perform a constant number of queries. Modifiers are available for filtering by pattern, for custom scale factors, and for asserting an exact count.
What are the common mistakes when using the n_plus_one_control matchers?
Using a memoised value inside the expectation block, so the code under test runs once instead of once per scale factor, and creating a record in a lazily memoised helper that the population callback then uses, because the record is removed from the database between runs while still being present in the test. The fix for the second one is to create the record eagerly, before the population runs.
Can n_plus_one_control work without creating more records?
Yes. If the action under test takes a parameter that changes how many records come back, you can change that parameter between runs instead of creating more data, using the current scale factor the matcher provides, so a paginated index can be checked without a test suite creating thousands of rows.
How does n_plus_one_control differ from a query detector library?
A detector inspects one run and reasons about the queries it saw, so it can report false positives and miss true negatives. The scale-based matcher compares runs at different sizes, which has neither problem for the code it covers, at the cost of only reporting on the paths you wrote a test for. The readme frames them as complementary rather than competing.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/palkan-n-plus-one-control)Was this useful?