Open-source project
charkost/prosopite avatar
charkost/prosopite

Prosopite detects N+1 queries by grouping SQL fingerprints against call stacks

Rails N+1 queries auto-detection with zero false positives / false negatives

1,916 stars61 forksRubyApache-2.0

At a glance

What is it?
Prosopite is an Apache-2.0 Rails gem that finds N+1 queries without a false-positive allowlist, by watching ActiveSupport instrumentation for repeated queries sharing a call stack and a fingerprint. It ships Rack and Sidekiq middleware so the scan covers jobs as well as controllers.
Who is it for?
Prosopite fits a Rails team that already tried the bullet gem and gave up on its false positives, especially one with Sidekiq jobs where query patterns are hard to reason about. It does not fit a codebase outside ActiveRecord and ActiveSupport instrumentation, since that is the only event source it subscribes to, and pg_query is required for any adapter other than MySQL or MariaDB.
Can I use it commercially?
Yes. Apache-2.0 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 137 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 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Detection is fingerprint plus call stack, nothing else

The whole mechanism is two sentences in the README. Prosopite monitors all SQL queries using Active Support instrumentation, then looks for the pattern present in every N+1 case: more than one query has the same call stack and the same query fingerprint.

That is a narrower and stricter rule than what most N+1 detectors apply. A detector that only counts similar queries will fire on a legitimately repeated lookup, because repetition is often the correct implementation. Prosopite requires repetition to come from the same place in the code. Two identical queries from different call sites are two independent operations, not an N+1.

The consequence shows up in the failure list the README gives as reasons the gem exists. A case such as `Chair.find(l.chair_id)` inside a loop over legs is detected, even though no ActiveRecord association is being traversed, because the fingerprint repeats under one stack. The same holds for query loops that only run after record creation, which the README notes usually appear in tests, and for the Mongoid case where a Mongoid document calls into an ActiveRecord model.

The output format is designed to be actionable. A report groups the offending SELECTs under a "N+1 queries detected" heading and prints a call stack pointing at application code lines, so the first frame in app/ is usually the loop you need to change.

The cases it catches that a Bullet-style association hook misses

The README positions Prosopite as a replacement rather than a supplement, and gives five specific cases it detects that the gem it was written against does not.

The first is N+1 queries after record creation, typically in tests: create a list of records, then read an association off each one in a loop. The second is the inverse case, a loop that queries directly with `find` on a foreign key rather than through an association. An association-instrumentation approach has nothing to hook in either of these.

The third is the collection-association shortcut. Calling `first`, `last` or `pluck` on a collection association inside a loop produces the same repeated fingerprint under the same stack, and is reported.

The fourth is type polymorphism through `#becomes`, where records are mapped into a different ActiveRecord class before their associations are read. The ActiveRecord class changes mid-loop, which breaks a detector that keys on the model class. The fifth is a Mongoid document including `Mongoid::Document` whose reader method calls `Chair.where(id: chair_id).first!`, with the foreign key aliased through a `field ... as: :chair_id` declaration. Cross-ORM code like that is invisible to anything watching only ActiveRecord associations.

The README is direct about why a new gem rather than a patch: bullet's core mechanism is described as completely different from Prosopite's.

Installing, and the pg_query requirement that decides your database

Installation is a Gemfile line:

ruby
gem 'prosopite'

If you are not using MySQL or MariaDB, the README says you should also add:

ruby
gem 'pg_query'

That second line is the one to notice. Fingerprinting is not free, and the gem needs a SQL parser per database to produce a stable fingerprint that ignores literal differences. So on SQLite, for instance, this is not an optional extra, and it is worth reading what that dependency does to your bundle before adopting the gem rather than after.

Then run the install:

bash
bundle install

or install the gem directly with:

bash
gem install prosopite

The repository is small in the way a gem should be: lib/, test/, a gemspec, a Rakefile and CI through GitHub Actions. The gem is published under the name prosopite on RubyGems, with the badge in the README pointing at the standard gem page.

Four ways to install the scan boundary, and one rule about tests

Detection is only as good as the span you scan, and Prosopite supports several places to put that boundary.

For controllers, an around_action wrapping Prosopite.scan and Prosopite.finish, guarded by a non-production environment check, covers every action in the application. For tests, the same two calls go into a before(:each) and after(:each) hook. For background work, there is middleware: a Rack middleware added to the Rails middleware stack from an initializer, and a Sidekiq server middleware for Sidekiq 6.5.0 or newer.

The Sidekiq middleware is the piece most worth wiring up, because a job that iterates over records is a textbook N+1 site and none of the controller-level approaches cover it. For applications on Sidekiq below 6.5.0, the README suggests guarding the snippet with a version check such as `Sidekiq::VERSION >= '6.5.0' && (Rails.env.development? || Rails.env.test?)` and removing it after upgrading.

For arbitrary code outside controllers and jobs, Prosopite.scan wraps a block, calls finish automatically at the end of it, and returns the block's result, so it can be used inline around a factory call.

The test rule is stated in capitals in the README: scan and finish should run before and after each test, and not before and after the whole suite. Wrapping a suite in one scan accumulates every example's queries into a single window, which makes the fingerprint matching report problems that do not exist in any individual test.

Notification channels decide whether anyone acts on the report

A detector nobody reads is a detector that gets muted. The configuration surface is a set of module-level toggles, all with small, documented defaults.

`Prosopite.min_n_queries` sets the minimum number of queries in a group before it is reported, defaulting to 2. Raising it is the blunt instrument for ignoring the many loop-bodies that run twice.

Three booleans choose the channel. `Prosopite.rails_logger` sends warnings to the Rails log, `Prosopite.prosopite_logger` sends them to log/prosopite.log, and `Prosopite.stderr_logger` writes to standard error. All three default to false, so a fresh install reports nowhere unless you turn one on, which in the development environment snippet is done through `config.after_initialize do Prosopite.rails_logger = true end`.

`Prosopite.raise = true` turns warnings into exceptions, and that is the setting that makes tests fail rather than print. There is also a scoped pair, `Prosopite.start_raise` and its corresponding stop call, which enable and disable raising for the span between them, plus a predicate to check whether it is active. That is how you wrap one code path that must be clean without failing the entire suite.

Two settings are for plumbing. `Prosopite.backtrace_cleaner` accepts a custom ActiveSupport::BacktraceCleaner and defaults to Rails.backtrace_cleaner, which controls how much framework noise appears in the call stack. `Prosopite.custom_logger` substitutes your own logger object. `Prosopite.enabled` turns the gem off entirely, defaulting to true.

Pause, resume, and the two suppression lists you will end up using

Real codebases need to stop looking for a moment. Prosopite.pause suspends collection and Prosopite.resume continues, with the code in between still running normally. The pair sits between two scan and finish calls, so a scan can be suspended around a known-heavy section rather than abandoned.

The two suppression lists are what a long-lived installation depends on, and they target different things.

`Prosopite.allow_stack_paths` takes substrings or regular expressions and ignores any notification whose call stack contains them. This is for infrastructure that legitimately queries in a loop, such as a permissions check or a serialisation layer you do not own.

`Prosopite.ignore_queries` takes regular expressions or exact strings and ignores notifications matching a specific SQL query, with a literal example of an exact-match string alongside a regex match.

The distinction between the two is worth being deliberate about. Stack-based suppression is coarse and survives query rewrites, but it silences every query from that code path. Query-based suppression is precise for one statement but breaks the moment the SQL changes. Prefer the stack list for third-party code and the query list only for a statement you know is correct as written.

Release cadence and what a zero-false-positive claim is worth

The repository is not archived and the last push was on 2026-06-07. Releases are 2.2.0 on 2026-04-16, 2.1.2 on 2025-09-19 and 2.1.1 on 2025-07-04, so the gap between 2.1.1 and 2.1.2 was long and the gap between 2.1.2 and 2.2.0 was shorter. The project has no homepage beyond the repository, and the description on the project itself is the claim of zero false positives and zero false negatives.

Treat that claim as a design goal rather than a guarantee. The mechanism it describes is sound: requiring both a matching fingerprint and a matching call stack removes the class of report that comes from unrelated queries that happen to look alike. But an absolute claim covers every query shape across every adapter, and the gem depends on a third-party parser for anything other than MySQL to do the fingerprinting. Two adapters can also normalise the same logical query differently.

Licence is Apache-2.0 with LICENSE.txt at the root, so there is no copyleft obligation on your application. The adoption cost is not the licence but the pg_query dependency, the per-test scan hooks you have to add everywhere, and the ongoing work of maintaining ignore lists as your schema changes.

Editorial conclusion

Prosopite fits a Rails team that already tried the bullet gem and gave up on its false positives, especially one with Sidekiq jobs where query patterns are hard to reason about. It does not fit a codebase outside ActiveRecord and ActiveSupport instrumentation, since that is the only event source it subscribes to, and pg_query is required for any adapter other than MySQL or MariaDB. First step is installing the gem and wrapping one representative test with Prosopite.scan and Prosopite.finish per example, rather than per suite, and seeing whether the zero-claim holds on code you did not write.

Frequently asked questions

How do I install Prosopite in a Rails application?

Add gem 'prosopite' to the Gemfile and run bundle install, or run gem install prosopite. If you are not using MySQL or MariaDB, the README says to add gem 'pg_query' as well.

How does Prosopite avoid false positives compared to the bullet gem?

Prosopite subscribes to Active Support instrumentation and reports only when more than one query shares both the same call stack and the same query fingerprint. The README states it was written after dealing with false positives and negatives using bullet, whose core mechanism it describes as completely different.

Can Prosopite detect N+1 queries in Sidekiq jobs?

Yes. A Sidekiq server middleware for Sidekiq 6.5.0 or newer is provided and added through the Sidekiq initializer. For older Sidekiq versions the README suggests guarding the snippet with a version check.

How do I make Prosopite fail my test suite on N+1 queries?

Set Prosopite.raise = true, typically in a config.after_initialize block in config/environments/test.rb, and wrap each example with Prosopite.scan in a before hook and Prosopite.finish in an after hook. The README warns against wrapping the whole suite instead of each test.

How do I ignore known N+1 reports in Prosopite?

Set Prosopite.allow_stack_paths with substrings or regexes matching the call stack, or Prosopite.ignore_queries with regexes or exact SQL strings. The first suppresses by code location, the second by query text.

Official sources

  1. charkost/prosopite on GitHub
  2. Issues
  3. License: Apache-2.0
  4. README
  5. Releases
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.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/charkost-prosopite.svg)](https://hysenlabs.com/projects/charkost-prosopite)