N+1 queries are a well-known topic, but there is an alternative to the usual counter_cache solution that often surprises people, so it is worth writing down.
The classic N+1
class Deal
has_many :coupons
end
class Coupon
belongs_to :deal
end
In the view:
<% @deals.each do |deal| %>
<div>
<%= deal.coupons.count %>
</div>
<% end %>
One query for the deals, one extra COUNT per deal. The Rails answer is counter_cache:
class Coupon
belongs_to :deal, counter_cache: true
end
<% @deals.each do |deal| %>
<div>
<%= deal.coupons_count %>
</div>
<% end %>
The N+1 is gone, and the view reads a column instead of running an aggregate.
CACHE = BUGS
If you have written software for more than fifteen minutes, you know that any cache will eventually cause bugs.
coupons_count is a column on deals, kept in sync by Rails callbacks.
Anything that bypasses those callbacks (a data migration, a delete_all, raw SQL, a dependent: :delete_all association) silently corrupts the value.
A slightly wrong number might be fine in some cases, but for anything that triggers a business decision, it is not.
class Deal
def claim
with_lock do
available = initial_quantity - coupons.count
coupons.create!(user:) if available > 0
end
end
end
Here coupons.count is the right call, not coupons_count.
If the cache is stale, you either oversell a limited resource or refuse a valid claim. Both would be serious bugs.
And of course this gets a test:
describe "#claim" do
context "when the counter_cache is not up-to-date" do
let(:deal) { create(:deal, initial_quantity: 2) }
before do
create_list(:coupon, 2, deal: deal)
Coupon.delete_all # real-world scenario: data-migrations and raw SQL do not update counter_cache
end
it "allows claiming nevertheless" do
expect(deal.coupons.count).to eq(0)
expect(deal.available_quantity).to eq(0) # wrong!
deal.claim
expect(deal.coupons.count).to eq(1)
end
end
end
The alternative
Here is the part that often gets overlooked: you do not actually have to choose between “N+1” and “cached column”.
You can compute the real count once, for the whole collection, lazily.
n1_loader lets you write it like this:
class Deal
n1_optimized :coupons_count do |deals|
total_per_deal = Coupon.group(:deal_id).where(deal: deals).count.tap { |h| h.default = 0 }
deals.each do |deal|
total = total_per_deal[deal.id]
fulfill(deal, total)
end
end
end
The view is the same as the counter_cache version:
<% @deals.each do |deal| %>
<div>
<%= deal.coupons_count %>
</div>
<% end %>
One extra GROUP BY query for the whole page, always the real count, no callbacks to maintain, no migrations to backfill, no reset_counters task to run when something drifts.
When to pick which
counter_cachewhen the value is purely informational, the model graph is simple, and you control every write path.- A real count (
coupons.countorn1_optimized) when the number drives a decision, when you also write through migrations or raw SQL, or when you simply do not want to think about cache invalidation.
The extra query has a cost. Stale numbers in a claim method have a bigger one.