Skip to content

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

ruby
# rails_api/Gemfile
group :development, :test, :staging, :docker do
  gem 'bullet'
end

The 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:

ruby
# 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
end

In 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:

ruby
# rails_api/config/environments/test.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.raise = false              # Log only, do not raise
end

In 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::N1QueryDetected immediately
  • 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: goals

Steps to fix:

  1. Read the alert. Identify the model and association. (In the example above: User + goals.)

  2. Find the code path. Locate the GraphQL resolver or interaction that loads the parent (users) then iterates over the child association (goals).

  3. 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 query
  4. Verify 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:

ruby
# 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
end

Do 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:

  1. Fix it immediately — the best approach. Use the error message to identify the association, add includes() or preload(), and resolve.

  2. Wrap the problematic code temporarily — use Bullet.skip_n_plus_one_query_enable to 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:

  1. Resolve the highest-risk queries (those in hot paths, like list views or GraphQL resolvers).
  2. File follow-up issues for the remaining N+1s.
  3. 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:

bash
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:

ruby
# 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
ruby
# 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
end

Key points:

  • define_singleton_method on 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 the GROUP BY count — a row with zero matches is absent from the grouped hash, not present with value 0. 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) keys is_participating off current_user.id specifically — 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 (see community_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_members was switched from object.members.count to the existing members_count counter-cache column — zero queries, no batching needed. Check for a counter cache before reaching for a GROUP BY preload.

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 fieldResolver preloadPreload helper
CommunityType#total_goals, TrendingCommunityType#total_goalsResolvers::CommunityQueries#communities, #trending_communitiespreload_total_goals
CommunityPostType#likesResolvers::CommunityQueries#community_feedpreload_post_like_counts
CommunityChallengeType#participant_count, #is_participatingResolvers::CommunityChallengeQueries#community_challenges, CommunityType#active_challenge/#upcoming_challenges/#past_challengesCommunityChallenge.batch_preload_stats! (shared model method, keyed to current_user)
UserType#has_ally, #todays_moodfallback 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-row user field)
  • user_search_result_type.rb, admin_action_type.rb
  • goal/challenge creator fields, admin_search_users/users connections, 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)

Loading…