Bullet: N+1 Query Detection
This document covers how Bullet detects N+1 queries in the Rails API, how it integrates with automatic preloading, and how to resolve or triage alerts. It also covers a related N+1 shape Bullet doesn't catch — type-layer preload-with-fallback — for GraphQL type fields that run a scoped aggregate per row.
How Bullet is wired
Bullet is configured in three places:
1. Gemfile
# rails_api/Gemfile
group :development, :test, :staging, :docker do
gem 'bullet'
endThe gem is available in development, test, staging, and Docker environments — not in production (where N+1 queries are already paid for and adding instrumentation would waste CPU).
2. Development environment
Bullet is configured to fail hard on N+1s during development:
# rails_api/config/environments/development.rb
config.after_initialize do
Bullet.enable = ENV['DISABLE_BULLET'] != 'true'
Bullet.raise = ENV["BULLET_RAISE"] == "true" # Raise on N+1s only when BULLET_RAISE=true
Bullet.rails_logger = true # Log N+1s to Rails log
Bullet.add_footer = true # Add alerts to page footer (in browser)
Bullet.bullet_logger = true # Log to bullet logger
endIn development, Bullet.raise is controlled by the BULLET_RAISE environment variable (defaults to false). When BULLET_RAISE=true, a N+1 is not a warning — it is a hard failure. Your server will crash with Bullet::Notification::N1QueryDetected, stopping the request immediately. This forces you to fix N+1s as you build rather than letting them slide into the codebase. Set BULLET_RAISE=true in your local environment for strict mode.
3. Test environment
Bullet is enabled but does not raise on N+1s in tests:
# rails_api/config/environments/test.rb
config.after_initialize do
Bullet.enable = true
Bullet.raise = false # Log only, do not raise
endIn test, Bullet.raise = false means N+1s are detected and logged to the Rails log, but do not cause test failures. This allows specs to run and log N+1 warnings without stopping, giving you visibility into query patterns while keeping your test suite runnable during active development.
What raise: true means
When Bullet.raise = true (i.e., BULLET_RAISE=true is set in development):
- Every N+1 query detected raises
Bullet::Notification::N1QueryDetectedimmediately - The request fails with an exception — no silent logging, no "continue anyway"
- The error message names the association and shows the query sequence
- Development server crashes until you fix the N+1
This is intentional. It catches regressions in real time as you code.
Local development
In development, set BULLET_RAISE=true to enable strict mode — your server will crash on any N+1, and you'll see the error message immediately. This is a feature — it ensures quality. Without BULLET_RAISE=true, N+1s are still logged but do not crash the server. If you absolutely need to disable Bullet entirely (rare), use the DISABLE_BULLET environment variable.
How to triage a Bullet alert
When Bullet surfaces an N+1, the error message follows this pattern:
N1 Query in User (should use #includes)
User: SELECT * FROM users WHERE id IN (…)
Association: goalsSteps to fix:
Read the alert. Identify the model and association. (In the example above:
User+goals.)Find the code path. Locate the GraphQL resolver or interaction that loads the parent (users) then iterates over the child association (goals).
Add
includes(). In the loader or interaction, preload the association:ruby# Before (N+1): users = User.all users.map { |u| u.goals } # Fires N queries # After (fixed): users = User.includes(:goals) users.map { |u| u.goals } # Fires 1 queryVerify in test. Run the test suite. The N+1 should disappear and the test should pass.
Goldiloader interaction
The goldiloader gem (rails_api/Gemfile, line 49) automatically preloads many associations without explicit includes(). It watches for collection access patterns and auto-preloads behind the scenes.
However, Bullet can false-positive on goldiloader-managed paths. If goldiloader already handles an association but Bullet still fires, use Bullet.skip_n_plus_one_query_enable to suppress the alert for that specific block:
# Goldiloader handles :goals automatically, but Bullet still fires
# (rare — only when goldiloader's heuristics miss the pattern)
Bullet.skip_n_plus_one_query_enable do
users.each { |u| u.goals.first } # No alert, even though goldiloader preloads
endDo not use Bullet.enable = false as a global disable. That hides real N+1s. Instead, skip only the specific block where goldiloader already does the work.
Dealing with development crashes
If a legitimate N+1 surfaces in development and your server crashes on it, you have two options:
Fix it immediately — the best approach. Use the error message to identify the association, add
includes()orpreload(), and resolve.Wrap the problematic code temporarily — use
Bullet.skip_n_plus_one_query_enableto suppress alerts for that specific block while you work on the fix:ruby# Temporary workaround while fix is in flight Bullet.skip_n_plus_one_query_enable do users.each { |u| u.goals } # N+1 suppressed, but logged end
Never disable Bullet globally in development — the crashes are features that prevent regressions.
Scope guard: limiting follow-ups
If a single PR surfaces more than 5 N+1 queries, it indicates systemic issues in the PR's design. In that case:
- Resolve the highest-risk queries (those in hot paths, like list views or GraphQL resolvers).
- File follow-up issues for the remaining N+1s.
- Cap the current PR at the highest-risk resolvers — do not try to fix everything in one PR.
This prevents PRs from ballooning in scope. The follow-ups stay tracked and visible.
Testing N+1 detection
In test environment, Bullet logs but does not raise. This means:
- N+1 queries do not automatically fail tests
- They appear in the Rails log (check
log/test.log) - You catch them during local test runs or in CI logs, not via test failures
To check for N+1s in your spec output, look for Bullet warnings in the test logs:
cd rails_api
bundle exec rspec spec/interactions/goals_spec.rb
# Check log/test.log for Bullet warnings like:
# "N1 Query in User (should use #includes)"However, N+1s are primarily caught during development — your server will crash if you trigger an N+1 while manually testing or browsing. This gives immediate feedback and prevents shipping them.
Type-layer N+1s: the preload-with-fallback pattern
Bullet (above) catches association-level N+1s — a missing includes() on a has_many/belongs_to traversal. It does not catch a different, easy-to-miss shape: a GraphQL type method that runs a scoped aggregate (.count, .exists?, .where(...).first) on an association, per row, inside a list query. That query fires fresh every time regardless of includes() — includes preloads the association's records, but a .where(...).count on top of it still issues its own SQL. Bullet has nothing to flag because there's no "missing preload"; the code is just re-querying by design.
This showed up concretely in CommunityPostType#likes (object.post_reactions.where(type: 'like').count): community_feed already did .includes(:post_reactions), but the scoped .count ignored the preload and ran once per post anyway (OBJ-1100).
The pattern
Fix it by preloading the count/existence value in the parent resolver (once, batched via GROUP BY) and reading it from the type with a fallback to the original per-row query:
# In the resolver that assembles the list (e.g. Resolvers::CommunityQueries):
def preload_total_goals(communities)
return communities if communities.empty?
goals_counts = CommunityGoal.where(community_id: communities.map(&:id)).group(:community_id).count
communities.each do |c|
c.define_singleton_method(:preloaded_total_goals) { goals_counts[c.id] || 0 }
end
communities
end# In the GraphQL type:
def total_goals
return object.preloaded_total_goals if object.respond_to?(:preloaded_total_goals)
object.community_goals.count # fallback: correct for single-object contexts, just unbatched
endKey points:
define_singleton_methodon each row, not a memoized hash on the type — the type instance is per-field-resolution, not per-row, so there's nowhere else to stash a batched value.|| 0(or an empty-set default) on theGROUP BYcount — a row with zero matches is absent from the grouped hash, not present with value0. Skipping this default silently misreports zero-count rows.- The fallback branch is not dead code. Any call site that returns the object without going through the batching resolver (a single-object query, a different list resolver) still gets a correct value — just unbatched. This is what makes it safe to leave a type field's non-preloaded call sites alone.
- Current-user- or time-scoped fields need a correctly-keyed preload, not just a batched one.
CommunityChallenge.batch_preload_stats!(challenges, current_user)keysis_participatingoffcurrent_user.idspecifically — a preload keyed globally instead of per-viewer would leak one user's participation state onto another user's response. This is the highest-risk mistake in this pattern; cover it with a multi-user spec fixture (seecommunity_challenge_queries_spec.rb), not just a single-user value-equality check. - A counter-cache column beats a preload when one already exists.
TrendingCommunityType#total_memberswas switched fromobject.members.countto the existingmembers_countcounter-cache column — zero queries, no batching needed. Check for a counter cache before reaching for aGROUP BYpreload.
Canonical example and current implementations
The original template is Resolvers::CommunityQueries#community_members (rails_api/app/graphql/resolvers/community_queries.rb), which preloads preloaded_goals_completed/preloaded_posts_count for CommunityMemberType. OBJ-1100 extended the same shape to:
| Type field | Resolver preload | Preload helper |
|---|---|---|
CommunityType#total_goals, TrendingCommunityType#total_goals | Resolvers::CommunityQueries#communities, #trending_communities | preload_total_goals |
CommunityPostType#likes | Resolvers::CommunityQueries#community_feed | preload_post_like_counts |
CommunityChallengeType#participant_count, #is_participating | Resolvers::CommunityChallengeQueries#community_challenges, CommunityType#active_challenge/#upcoming_challenges/#past_challenges | CommunityChallenge.batch_preload_stats! (shared model method, keyed to current_user) |
UserType#has_ally, #todays_mood | fallback shape added; not wired to any resolver yet (see follow-up below) | n/a |
Follow-up: UserType fields are not batched everywhere
UserType#has_ally/#todays_mood got the preload-with-fallback shape in OBJ-1100, but no resolver currently sets preloaded_has_ally/preloaded_todays_mood — at the time, no community-context list query selected either field per-row (verified against ionic_frontend/src/constants/graphql/). UserType is returned from far more places than community list views, and the following collection call sites still run the unbatched per-row query if a future query selects hasAlly/todaysMood on them:
community_member_type.rb,challenge_participant_type.rb(per-rowuserfield)user_search_result_type.rb,admin_action_type.rb- goal/challenge
creatorfields,admin_search_users/usersconnections,suggested_community_type.members
This is a deliberate narrow scope (Roy's triage + Orion's task package on OBJ-1100), not an oversight — the fallback makes every one of these call sites correct today, just unbatched. If a future list view selects hasAlly or todaysMood per-row and Bullet-style query volume becomes visible there, wire a preloaded_has_ally/preloaded_todays_mood preload into that specific resolver following the pattern above — don't add a global preload speculatively.
See also: docs/architecture/proposals/periodic-load-testing.md — the Cloud SQL connection-pool cliff this pattern reduces pressure on.
Last updated: 2026-07-04 (OBJ-1100: type-layer preload-with-fallback pattern)