Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 050 / 100

Counter caches: Ruby on Rails best practices explained

Fast counts without COUNT queries.

Expona Admin5 min read

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.

ruby
class Post < ApplicationRecord
  has_many :comments, dependent: :destroy
end

class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
end

Next, we create a migration to add the counter column to the posts table:

ruby
class AddCommentsCountToPosts < ActiveRecord::Migration[7.0]
  def change
    add_column :posts, :comments_count, :integer, default: 0
  end
end

That'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:

ruby
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: :total_comments
end

Then 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.

ruby
# 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
ruby
<!-- 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:

ruby
comment = Comment.create!(post_id: 1, body: "Great post!")
# Rails executes:
# UPDATE posts SET comments_count = comments_count + 1 WHERE id = 1

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

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

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

ruby
Post.find_in_batches do |batch|
  Post.reset_counters(batch.map(&:id), :comments)
end

For 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.

ruby
class Product < ApplicationRecord
  has_many :reviews, dependent: :destroy
end

class Review < ApplicationRecord
  belongs_to :product, counter_cache: true
end

Social 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: true to the belongs_to side and create a {association_name}_count integer 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles