Counter caches: Ruby on Rails best practices explained
When you're building features that display counts—like the number of comments on a post or reviews for a product—you quickly hit a performance wall. Every time you render a list, you're firing off additional COUNT(*) queries that compound as your data grows. Counter caches are Rails' elegant solution to this problem, letting you denormalize counts onto the parent model and avoid those expensive queries entirely. We'll walk through how to implement them, when they're worth the trade-off, and how to keep them accurate in production.
Understanding counter_cache: true on belongs_to
The foundation of Rails counter caching is straightforward: when you add counter_cache: true to a belongs_to association, Rails automatically maintains a count column on the parent record whenever a child record is created or destroyed.
Let's say we're building a blogging platform. A Post has many Comments.
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
endNext, we create a migration to add the counter column to the posts table:
class AddCommentsCountToPosts < ActiveRecord::Migration[7.0]
def change
add_column :posts, :comments_count, :integer, default: 0
end
endThat's it. Now whenever a comment is created, Rails increments post.comments_count. When a comment is destroyed, it decrements. No extra queries needed when you want to display the count.
The naming convention matters: Rails looks for a column called {association_name}_count. If you need a different column name, you can customize it:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: :total_comments
endThen add a migration with that custom column name instead.
How counts are stored on the parent
The magic happens at the database level. When you create or destroy a child record, Rails uses SQL UPDATE statements to increment or decrement the counter. This is much faster than recalculating a count every time.
Consider a real scenario: rendering a list of 50 posts with comment counts. Without counter caching, you'd run one query for posts and then either 50 individual queries (N+1 problem) or a complex join and group query. With counter caching, you have the counts right there on each post object—zero extra queries.
# app/controllers/posts_controller.rb
def index
@posts = Post.order(created_at: :desc).limit(50)
# @posts already have comments_count loaded. No extra queries.
end<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
<div class="post">
<h3><%= post.title %></h3>
<p><%= post.comments_count %> comments</p>
</div>
<% end %>In your view, post.comments_count is just an integer attribute—no query involved.
Under the hood, when you save a new comment:
comment = Comment.create!(post_id: 1, body: "Great post!")
# Rails executes:
# UPDATE posts SET comments_count = comments_count + 1 WHERE id = 1This atomic increment ensures accuracy even under concurrent writes, though we'll cover edge cases below.
Resetting counter caches when they drift
In production, counters can drift. Data migrations, bulk deletes, or bugs in custom logic can cause the cached count to fall out of sync with reality. Rails provides a method to reset them:
Post.reset_counters(post_id, :comments)
# Or for multiple records:
Post.reset_counters([1, 2, 3], :comments)This method recalculates the actual count from the database and updates the cache column. Use it sparingly and only when you know there's drift.
A common scenario: you're deleting comments in a data migration or Rake task without going through the ORM. The counter won't be updated. When you discover this, reset it:
# In a Rake task or Rails console
affected_post_ids = Comment.where(spam: true).pluck(:post_id).uniq
Comment.where(spam: true).delete_all
Post.reset_counters(affected_post_ids, :comments)If you're resetting many records, batch them to avoid memory issues:
Post.find_in_batches do |batch|
Post.reset_counters(batch.map(&:id), :comments)
endFor automated detection, you might add a periodic job (weekly or daily) that checks and repairs counters for records where counts seem suspicious. It's not foolproof, but it's a good safety net.
Great for lists with counts—practical use cases
Counter caches shine in specific scenarios. Any time you're rendering a collection and displaying counts, they're worth considering.
E-commerce product listings: Showing the number of reviews per product. Your product feed can now display star counts and review counts without hitting the reviews table.
class Product < ApplicationRecord
has_many :reviews, dependent: :destroy
end
class Review < ApplicationRecord
belongs_to :product, counter_cache: true
endSocial media feeds: Displaying like and comment counts on posts. When you're scrolling through 100 posts, that's 200+ avoided queries.
Community platforms: Showing topic counts per category, message counts per conversation. The pattern is identical and the performance gain is substantial.
When not to use them: If you rarely display the count, or if the count changes so frequently that the cache becomes a bottleneck, skip it. Also avoid them for derived counts that depend on complex logic (like active vs. inactive comments)—stick to simple child counts.
For a SaaS dashboard showing team members per organization, counter caching is perfect. For a feature that filters comments by status and shows a count, it's the wrong tool.
Common mistakes
Forgetting the migration: Adding counter_cache: true without adding the database column causes silent failures. Rails won't error; it'll just skip incrementing.
Manual updates: If you forget to use the ORM and directly execute SQL deletes or updates, the cache drifts. Always delete child records through Rails when possible, or reset the counter afterward.
Stale data in tests: If tests create records then inspect counts without flushing the cache, you may see stale values. Use reload or reset_counters in your test setup.
Assuming thread safety: Multiple processes incrementing a counter simultaneously can cause issues. Rails uses atomic SQL increments, but race conditions can still occur. Don't rely on counter caches for financial or quota-critical counts.
Forgetting the default value: Always set default: 0 on the counter column in your migration. Existing parent records without children will otherwise have a NULL count.
Key takeaways
- Counter caches denormalize counts onto parent records, eliminating queries on list views.
- Add
counter_cache: trueto thebelongs_toside and create a{association_name}_countinteger column with a default of 0. - Rails automatically maintains accuracy through atomic SQL increments and decrements when using the ORM.
- Reset drifted counters with
Post.reset_counters(post_ids, :comments)when data mutations bypass the ORM. - Use counter caches for simple child counts displayed in collections; avoid them for complex or frequently-changing derived metrics.
- Test thoroughly—cache invalidation is a classic hard problem, even when Rails handles most of it for you.






Comments
Loading comments…