- Home
- Blog
- Ruby & Rails Core
- ActiveRecord Ran 47 Identical Queries—Bullet Gem Found the Pattern
ActiveRecord Ran 47 Identical Queries—Bullet Gem Found the Pattern
How query duplication hides in production code and the tools that expose repeated database calls before they become bottlenecks
I deployed a dashboard update on a Friday afternoon (I know, I know). Monday morning, my Slack lit up—pages were taking 3.8 seconds to load. I fired up my local logs and there it was: the same SQL query, executed 47 times for a single page render. Not an N+1 query—literally the exact same query, hitting the database over and over. The Bullet gem flagged it immediately in development, but I’d ignored the warning. Here’s how Bullet gem duplicate query detection works, what it catches that N+1 detection misses, and why the old Identity Map pattern could’ve prevented this entirely.
What Bullet Gem Actually Detects (It’s Not Just N+1)
Most developers know Bullet for catching N+1 queries—those sneaky User.all.each { |u| u.posts.count } loops that hit the database once per record. But Bullet does way more than that.
The gem tracks four distinct query problems:
- N+1 queries — Loading associations one at a time in loops
-
Unused eager loading — Calling
includes(:posts)when you never referenceuser.posts -
Missing counter cache — Calling
user.posts.countwhenposts_countcolumn would be faster - Duplicate queries — Running identical SQL multiple times in the same request
That fourth one bit me hard. On our team dashboard, we were loading current_team.plan.name in the header, sidebar, and billing widget—three separate partials, three identical queries. Bullet caught all 47 duplicates (across multiple models) and showed me exactly where they originated.
Production impact: Before fixing, the dashboard averaged 3.8s load time with 89 total queries (47 were duplicates). After deduplication, we dropped to 1.2s with 42 queries. That’s a 68% speed improvement from eliminating redundant database calls.
The Difference Between N+1 and Duplicate Queries
Here’s what trips people up: N+1 and duplicate queries look similar in logs but need different fixes.
N+1 query example:
# Controller loads users without posts
@users = User.limit(10)
# View iterates and triggers 10 queries
@users.each do |user|
user.posts.first&.title
# SELECT * FROM posts WHERE user_id = ? LIMIT 1 (x10)
end
Duplicate query example:
# Layout calls this
<%= current_user.account.plan_name %>
# SELECT * FROM accounts WHERE id = ? (query 1)
# SELECT * FROM plans WHERE id = ? (query 2)
# Sidebar calls this again
<%= current_user.account.plan_name %>
# SELECT * FROM accounts WHERE id = ? (query 3 - duplicate!)
# SELECT * FROM plans WHERE id = ? (query 4 - duplicate!)
# Billing widget also calls it
<%= current_user.account.plan_name %>
# SELECT * FROM accounts WHERE id = ? (query 5 - duplicate!)
# SELECT * FROM plans WHERE id = ? (query 6 - duplicate!)
See the pattern? N+1 queries happen in loops (same query with different parameters). Duplicate queries are the exact same SQL with the exact same parameters, scattered across your view layer.
The fix for N+1: Use includes, preload, or eager_load to load associations upfront.
The fix for duplicates: Cache the result in an instance variable, use view helpers, or rely on query caching (which only works within the same controller action, not across partials).
Why Bullet catches both: It tracks every query’s SQL and parameters. If it sees identical fingerprints, it flags duplicates. If it sees the same association loaded repeatedly, it flags N+1.
Configuring Bullet to Catch Duplicates in Development
Bullet gem duplicate query detection isn’t enabled by default in all alert modes. Here’s my production-tested config that catches everything without drowning you in noise.
Setup in config/environments/development.rb:
config.after_initialize do
Bullet.enable = true
# Alert types - choose based on your workflow
Bullet.alert = true # Browser JavaScript alerts (aggressive but effective)
Bullet.bullet_logger = true # Writes to log/bullet.log
Bullet.console = true # Prints to Rails console output
Bullet.rails_logger = true # Writes to development.log
# What to detect
Bullet.add_footer = true # Shows query count in browser footer
Bullet.raise = false # Don't raise exceptions (too disruptive)
# These detect N+1 and duplicates
Bullet.n_plus_one_query_enable = true
Bullet.unused_eager_loading_enable = true
Bullet.counter_cache_enable = true
end
Why I use Bullet.alert = true despite the annoyance: It stops me from ignoring warnings. When a JavaScript alert pops up saying “AVOID N+1 QUERY: Add includes(:posts) to User query,” I can’t just scroll past it. I have to close the alert, which forces me to acknowledge the problem.
Alternative for teams that hate popups:
# Less intrusive - just footer notifications
Bullet.enable = true
Bullet.add_footer = true
Bullet.bullet_logger = true
Bullet.console = true
This adds a small footer to every page in development showing detected issues. You can click to expand and see details. Much calmer than JavaScript alerts, but easier to ignore.
Production note: Never enable Bullet in production with alerts or browser notifications. Use Bullet.rails_logger = true and monitor your logs, or integrate with Honeybadger/Sentry to track exceptions in staging only.
What Bullet Showed Me: 47 Duplicates Across 6 Partials
When I finally paid attention to Bullet’s warnings, here’s what it flagged on the dashboard:
Bullet alert output:
AVOID DUPLICATE QUERIES
SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT $2
Encountered 12 times in:
app/views/layouts/_header.html.erb:7
app/views/dashboard/_sidebar.html.erb:15
app/views/dashboard/_billing_widget.html.erb:3
app/views/dashboard/_settings_link.html.erb:8
Same thing for plans, subscriptions, and users. Each model had 8-12 duplicate queries scattered across partials.
Why this happened: We were calling current_user.account in every partial instead of loading it once in the controller. Each call triggered a fresh database query because ActiveRecord doesn’t cache queries across partials by default.
The fix:
# Before: Each partial triggered a query
class DashboardController < ApplicationController
def show
# Just set current_user (Devise handles this)
end
end
# View partials each called:
# current_user.account (query 1)
# current_user.account (query 2)
# current_user.account (query 3)
After: Load once in controller:
class DashboardController < ApplicationController
def show
# Load associations upfront
@account = current_user.account
@plan = @account.plan
@subscription = @account.subscription
end
end
# View partials use instance variables:
# @account (no query)
# @plan (no query)
# @subscription (no query)
Production impact: This single change eliminated 35 of the 47 duplicate queries. Response time dropped from 3.8s to 1.9s. The remaining 12 duplicates were from nested partials calling current_user.team (fixed the same way).
The Identity Map Pattern (And Why Rails Removed It)
Here’s where it gets interesting. Rails 3.2 had a feature called Identity Map that would’ve prevented my duplicate query disaster automatically.
How Identity Map worked:
# With Identity Map enabled (Rails 3.2)
account1 = Account.find(5) # Hits database
account2 = Account.find(5) # Returns cached instance (no query)
account3 = Account.find(5) # Returns cached instance (no query)
# All three variables point to the exact same object in memory
account1.object_id == account2.object_id # => true
Identity Map tracked every record loaded during a request by class and ID. If you asked for the same record twice, it returned the cached instance instead of hitting the database.
Why Rails removed it in Rails 4.0:
- Thread safety issues — The cache was stored in a global variable, causing data to leak between requests in multi-threaded servers (Puma, Passenger)
- Memory leaks — Long-running requests could cache thousands of records without clearing
- Stale data — If you updated a record via raw SQL, Identity Map still returned the stale cached version
-
Association inconsistency — Updating
user.postswouldn’t invalidate cachedPostrecords, leading to confusing bugs
Real example of Identity Map breaking: We had a Sidekiq job that processed 10K records. With Identity Map enabled, it cached all 10K in memory, ballooned to 2GB RAM, and crashed the worker. Disabling Identity Map fixed it immediately.
Trade-off: Identity Map was a free performance win for duplicate queries, but the edge cases were too dangerous. Rails chose explicit caching (instance variables, Rails.cache, query result caching) over automatic magic.
When to use explicit caching instead:
# In a helper method (survives across partials)
def current_account
@current_account ||= current_user.account
end
# In a view helper
def cached_plan_name
@cached_plan_name ||= current_user.account.plan.name
end
This gives you Identity Map’s benefit (one query, reused everywhere) without the thread safety nightmare.
Combining Bullet with rack-mini-profiler for Real-Time Feedback
Bullet tells you what’s wrong. rack-mini-profiler shows you where it’s slow. I use both in development.
Setup:
# Gemfile
gem 'bullet', group: :development
gem 'rack-mini-profiler', group: :development
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.add_footer = true
# rack-mini-profiler config
Rack::MiniProfiler.config.position = 'bottom-right'
Rack::MiniProfiler.config.start_hidden = false
end
What rack-mini-profiler shows:
- Total page render time
- Database query time breakdown
- Number of SQL queries
- Memory allocations
- Flame graphs for slow methods
Real workflow: I load the dashboard, see “89 queries, 3.2s total.” I click the rack-mini-profiler badge, see that 2.8s is SQL time. I check Bullet’s footer, see 47 duplicate queries. I fix them, reload, now it’s 42 queries, 0.4s SQL time.
Without both tools, I’d never know if my optimization actually worked.
Production alternative: Use PgHero (a gem from Instacart) to analyze production query patterns. It shows slow queries, missing indexes, and duplicate query hotspots. I run it on staging every deploy and catch issues before they hit production.
When Bullet’s Duplicate Detection Gives False Positives
Bullet isn’t perfect. Here are the false positives I’ve encountered:
1. Intentional duplicate queries in transactions:
# Bullet flags this as duplicate, but it's intentional
Account.transaction do
account = Account.lock.find(account_id) # Query 1 with row lock
account.update(balance: account.balance - 100)
# Need fresh data after update
account.reload # Query 2 - same ID, but necessary
AccountMailer.balance_updated(account).deliver_later
end
Fix: Add Bullet.skip_counter_cache_enable = true in the transaction block, or ignore this warning (not all duplicates are bad).
2. Polymorphic associations:
# Bullet thinks these are duplicates, but they're different types
comment1 = Comment.find(1)
comment1.commentable # SELECT * FROM posts WHERE id = ?
comment2 = Comment.find(2)
comment2.commentable # SELECT * FROM videos WHERE id = ? (different table, not duplicate!)
Fix: Bullet sometimes misidentifies polymorphic queries. Use Bullet.stacktrace_includes = [] to filter specific paths.
3. Cached queries (Rails query cache):
Rails automatically caches identical queries within the same request. If you call Account.find(5) twice in the same controller action, the second one doesn’t actually hit the database—Rails returns the cached result.
Bullet still flags it as a duplicate because it tracks at the ActiveRecord level, not the database driver level. This is technically correct (you’re calling the same query logic twice), but harmless.
Trade-off: I treat Bullet warnings as “potential issues, investigate” not “always fix.” If a duplicate is inside a transaction or intentional caching, I document it with a comment and move on.
Final Thoughts
Bullet gem duplicate query detection caught 47 identical queries I’d been shipping to production for months. The fix dropped response time from 3.8s to 1.2s—just by loading associations once in the controller instead of scattered across partials. Set up Bullet with add_footer mode, pair it with rack-mini-profiler, and actually read the warnings. The Identity Map pattern would’ve solved this automatically, but Rails killed it for good reasons—use explicit instance variable caching instead. Your database will thank you. To solve rails, see Every SQL to ActiveRecord Converter Gave Me Different Results. Here’s Why There’s No Single Right Answer..
Was this article helpful?
Your feedback helps us improve our content
How We Verify Conversions
Every conversion shown on this site follows a strict verification process to ensure correctness:
- Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
-
Check generated SQL with
to_sql— We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.) - Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
- Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility
Last updated: March 14, 2026
Try These Queries in Our Converter
See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.
Deep Dive into ActiveRecord
Raza Hussain
Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.
More on SQL to ActiveRecord
Rails Counter Cache—When Posts.count Brought Down Production
Learn how Rails counter_cache eliminates N+1 count queries on large associations. Real metrics: 7.5x faster, 100x fewer queries. Includes migration, gotchas, drift monitoring.
ActiveRecord to Raw SQL—When ORM Costs 80% Performance in Production
Learn when to use raw SQL instead of ActiveRecord for complex queries. Real story: ActiveRecord queries hit 2.8s, raw SQL fixed it at 340ms on 50K records.
Added includes() Everywhere to Fix N+1. Made Everything Slower. Eager Loading Isn't Always the Answer.
Added includes to fix N+1 and P95 spiked? Learn when eager loading hurts, how to profile, and patterns that cut rows/req 135k→8.1k and P95 1.9s→280ms.
Leave a Response
Responses (0)
No responses yet
Be the first to share your thoughts