---
title: "RR011 – Use counter_cache to Avoid Count N+1"
impact: medium
impactDescription: "post.comments.count in a collection loop fires one COUNT query per row; counter_cache collapses this to zero extra queries."
tags: [ruby, rails, performance, database]
---

# RR011 – Use counter_cache to Avoid Count N+1

## Rule

When displaying association counts in collection views or API responses (comment counts, like counts, etc.), use `counter_cache: true` on the `belongs_to` side instead of computing `association.count` in-loop.

## Why

`post.comments.count` (or `.size` when collection is not loaded) executes a `COUNT(*)` SQL query. In a list of 50 posts this fires 50 extra queries. `counter_cache` maintains a denormalized `comments_count` column on the parent table updated atomically by Rails on create/destroy.

## Wrong

```ruby
# In API serializer or view — fires N COUNT queries
posts.each do |post|
  count = post.comments.count    # ❌ 1 COUNT query per post
end

# JSON response that triggers N+1
render json: @posts.map { |p| { id: p.id, comment_count: p.comments.count } }
```

## Correct

```ruby
# Migration: add the cache column
add_column :posts, :comments_count, :integer, default: 0, null: false
# Backfill: Post.find_each { |p| Post.reset_counters(p.id, :comments) }

# Model association
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
  # Rails now auto-increments/decrements posts.comments_count
end

class Post < ApplicationRecord
  has_many :comments
end

# Usage — no extra query
posts.each do |post|
  count = post.comments_count    # ✅ reads the cached column, zero extra queries
end

render json: @posts.map { |p| { id: p.id, comment_count: p.comments_count } }
```

## Notes

- Reset cache after backfills: `Post.find_each { |p| Post.reset_counters(p.id, :comments) }`.
- If using soft-delete (`acts_as_paranoid`, `discard`), counter_cache won't exclude soft-deleted records — use a custom query in that case.
- Alternative when migration is not possible: `Post.includes(:comments)` + `post.comments.size` (`.size` uses already-loaded collection, no extra query).
