counter_cache and touch — Derived Data in Rails Without the Extra Query
An index page listing fifty posts, each showing a comment count, issues fifty-one queries. The fix is well known — counter_cache — and it’s usually applied incorrectly, either by forgetting the backfill, by trusting it through operations that bypass callbacks, or by pairing it with touch in a way that turns every comment insert into a lock contention problem. Both features are simple. The failure modes are not.
The Query You’re Trying to Avoid
Example:
<% @posts.each do |post| %>
<h3><%= post.title %></h3>
<span><%= post.comments.count %> comments</span>
<% end %>
count on an unloaded association issues SELECT COUNT(*) FROM comments WHERE post_id = ? every iteration. Eager loading doesn’t help — includes(:comments) loads all the comment rows just to count them, which trades fifty small queries for one enormous result set.
counter_cache stores the number in a column on posts and keeps it current.
Setting It Up
Migration:
class AddCommentsCountToPosts < ActiveRecord::Migration[7.1]
def change
add_column :posts, :comments_count, :integer, null: false, default: 0
end
end
Example:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
class Post < ApplicationRecord
has_many :comments
end
The declaration goes on the belongs_to side — the model that owns the foreign key. This trips people up constantly; putting it on has_many does nothing.
Now post.comments.count reads the column instead of querying:
post.comments.count # => 12, no COUNT query
post.comments.size # => 12, uses the counter when the association isn't loaded
The column name defaults to <association>_count. Override it when you need something else:
belongs_to :post, counter_cache: :total_comments
The Backfill Everyone Forgets
Adding the column gives every existing row a count of zero. The counter only increments from that point forward, so an app with a million existing comments now displays “0 comments” everywhere.
Example:
class BackfillCommentsCount < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def up
Post.in_batches(of: 1_000) do |batch|
batch.update_all(
"comments_count = (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id)"
)
sleep 0.05
end
end
def down
# no-op
end
end
Rails also ships Post.reset_counters(post.id, :comments) for one record, which is what you want in a repair rake task rather than a bulk migration — it issues one query per record.
Order matters on deploy: add the column with a default, deploy the code that maintains it, then backfill. Backfilling before the code ships leaves a window where new comments don’t increment.
What Silently Breaks the Counter
counter_cache is implemented with ActiveRecord callbacks. Anything that skips callbacks skips the counter.
delete_all—post.comments.delete_allremoves rows without instantiating models. Counter unchanged.destroy_allworks but is slow.insert_all/upsert_all— bulk inserts bypass the whole callback chain by design.update_column/update_allon the foreign key — reassigning a comment to a different post this way updates neither counter.- Raw SQL, database triggers, ETL jobs, direct psql — obviously.
dependent: :delete_allon the parent association — deletes children without callbacks, which is fine when the parent is going away too, but not if a counter elsewhere depends on them.
The counter drifting is not hypothetical; on a system with any bulk operations it’s a matter of when. Add a scheduled reconciliation:
Example:
class ReconcileCountersJob < ApplicationJob
def perform
Post.find_each do |post|
actual = post.comments.count
next if actual == post.comments_count
Rails.logger.warn("Counter drift on Post##{post.id}: #{post.comments_count} → #{actual}")
Post.reset_counters(post.id, :comments)
end
end
end
Run it weekly off-peak. If it never finds drift, you’ve lost nothing. If it does, you want to know before a customer does.
Conditional and Scoped Counters
Rails 6.1+ lets you skip the increment conditionally, which covers the common “count only published items” case.
Example:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
after_save :sync_approved_counter, if: :saved_change_to_approved?
private
def sync_approved_counter
delta = approved? ? 1 : -1
Post.update_counters(post_id, approved_comments_count: delta)
end
end
update_counters issues a single atomic UPDATE posts SET approved_comments_count = approved_comments_count + 1, which is safe under concurrency in a way that read-modify-write is not. Use it any time you’re maintaining a counter by hand.
touch — Cascading Timestamps for Cache Keys
touch updates the parent’s updated_at whenever a child changes. Its main purpose is fragment cache invalidation via Russian-doll caching.
Example:
class Comment < ApplicationRecord
belongs_to :post, touch: true
end
<% cache post do %>
<h3><%= post.title %></h3>
<% post.comments.each do |comment| %>
<% cache comment do %>
<%= comment.body %>
<% end %>
<% end %>
<% end %>
The outer cache key includes post.updated_at. Without touch, editing a comment invalidates the inner fragment but leaves the stale outer one wrapping it. With touch, both keys change.
Touch a specific column instead of updated_at when you don’t want to disturb the main timestamp:
belongs_to :post, touch: :comments_updated_at
Where touch Gets Expensive
This is the part that bites at scale. touch on a hot parent means every child write also writes the parent row.
Consider a Company with fifty thousand Events, each belongs_to :company, touch: true. Ten events per second means ten updates per second to one row. Every one of those takes a row lock. Under PostgreSQL’s MVCC each update writes a new row version, so the tuple bloats and autovacuum starts working overtime. Deadlocks appear in transactions that touch the parent in a different order.
Three mitigations, roughly in order of preference:
1. Don’t touch — build the cache key from the children
<% cache [post, post.comments.maximum(:updated_at)] do %>
One cheap indexed aggregate, no writes at all.
2. Touch asynchronously and coalesce
class Comment < ApplicationRecord
belongs_to :post
after_commit :schedule_touch, on: [:create, :update]
private
def schedule_touch
TouchPostJob.set(wait: 30.seconds).perform_later(post_id)
end
end
Debounce by discarding duplicate jobs for the same parent within the window. You trade cache-invalidation latency for write throughput, which is usually the right trade.
3. Skip the chain for bulk operations
Comment.no_touching do
importer.run
end
no_touching disables the callback for the block, then touch the parents once at the end.
Pro-Tip:
counter_cacheandtouchtogether on the samebelongs_toproduce oneUPDATEon the parent per child write — and that UPDATE holds a row lock for the duration of the enclosing transaction, not just the statement. If two transactions insert comments on the same post in different orders relative to other locked rows, you get a deadlock. The symptom is intermittentPG::TDeadlockDetectedunder load with a stack trace pointing at a plainComment.create. Before adding either feature to a high-write association, ask how many writes per second the parent will absorb. Past a few per second on a single row, move the counter to a periodic aggregate or a separate counters table with the row split by shard key.
Conclusion
counter_cache removes a whole class of N+1 for count displays and costs one integer column, provided you backfill in the right order and accept that anything bypassing callbacks will drift it. A weekly reconciliation job turns that drift from a correctness bug into a logged warning. touch solves nested cache invalidation elegantly and becomes a write-throughput problem on hot parents — reach for a maximum(:updated_at) cache key before reaching for it. Both features are one line to add, which is exactly why they get added without asking how many writes per second the parent row will see.
FAQs
Q1: Does counter_cache work with polymorphic associations?
Yes, as long as each target model has the counter column. Declare it on the polymorphic belongs_to and add <association>_count to every type it can point at.
Q2: Why does post.comments.count still hit the database sometimes?
count always queries when the association is already loaded or when a scope is chained (post.comments.approved.count). Use size for the counter-aware version — it reads the counter if available, the loaded array if loaded, and queries otherwise.
Q3: Is update_counters safe under concurrency?
Yes. It emits SET col = col + n so the increment happens in the database, avoiding the lost-update problem of reading a value into Ruby and writing it back.
Q4: Can I have a counter cache without the callback overhead?
A database trigger maintains the count regardless of how rows are written, including bulk inserts and raw SQL. The trade is that the logic lives outside the application and outside your test suite — worth it for tables written by multiple systems, overkill otherwise.
Q5: Does touch: true cascade up multiple levels?
Yes, if each level declares it. Comment → Post → Blog with touch: true on both associations updates all three timestamps on one comment save, which is also how a seemingly small write turns into three row locks.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀