Counter caches by hand
A counter cache is the right answer to a real problem. Rendering a list of parents and asking each
one how many children it has is the N+1 that
N+1 queries in Rails is about, and no amount of includes fixes
it, because COUNT(*) is not a row you can preload. PostgreSQL has no stored row count to read
either, which is the same reason a page-number bar costs a scan in
Pagination without a gem. A column that already holds the number
turns that list into one query.
Rails ships the mechanism, it is four characters of configuration, and this site uses it: Comment
declares belongs_to :lead, counter_cache: :comments_count, and the admin's lead list renders a
comment count per row without a single count query. That is the good case, and most counting is the
good case.
The rest of this post is the other case. Three questions come up constantly about the rails counter cache, and all three have the same answer, which is that Active Record will not do it: counting only some of the children, counting through a join table, and counting on a row that cannot afford to be touched. Every claim below was reproduced against activerecord 8.1.3.1, and the SQL is copied out of the log.
What the option actually hooks
counter_cache is two callbacks and nothing else. In active_record/counter_cache.rb the private
half of the module is the whole feature:
def _create_record(attribute_names = self.attribute_names)
id = super
counter_cached_association_names.each do |association_name|
association(association_name).increment_counters
end
id
end
and a matching destroy_row that decrements, guarded so a child destroyed by its parent's
dependent: :destroy does not decrement a row that is going away anyway. Creation and destruction.
That is the list.
Everything outside those two moments drifts silently. Comment.where(lead_id: 7).delete_all deletes
rows without instantiating them, so no destroy_row runs and comments_count keeps its old value.
A bulk update_all that moves children between parents updates no counter on either side. A row
inserted by psql, by a fixture loaded with insert_all, or by another service sharing the database
is invisible to the callback. Active Record gives you reset_counters(id, :comments) for exactly
this, which recounts with a GROUP BY and writes the truth back, and a counter cache on a table
anything else writes to needs that on a schedule.
The third moment people expect and do not get is an update. A comment that gets hidden, soft deleted, or unpublished is still a row, so the counter does not move. The counter counts rows, not states.
The column name Rails derives for you
The convention is documented as #{table_name}_count, and the code is slightly more specific than
the sentence. Reflection#counter_cache_column on a belongs_to:
counter_cache[:column] || -"#{active_record.name.demodulize.underscore.pluralize}_count"
active_record here is the class declaring the belongs_to, and the interesting call is
demodulize. A Blog::Comment that declares belongs_to :post, counter_cache: true asks its parent
for comments_count, not blog_comments_count, whatever the join table is called. Namespace your
models later and the counter keeps pointing at the same column, which is the behaviour you want and
not the one the documented convention describes.
Two more facts worth having before you add the column. The counter is read by size, any?,
empty? and none?, through CollectionAssociation#size and HasManyAssociation#count_records,
which means the column becomes load-bearing the moment you declare the option, before you have
backfilled anything. Rails has an answer for that window: counter_cache: { active: false } keeps
the column maintained on create and destroy while making those methods go on asking the database, so
you can backfill a large table without post.comments.any? lying in production for an hour.
And the counter belongs to the parent row, so anything that can write the parent can corrupt it. The
belongs_to documentation suggests attr_readonly :comments_count on the parent class for that
reason.
Counting only some of the children
The first question the autocomplete asks about a rails counter cache is how to give it a condition,
and there is no option for one. A scope on the has_many does not become a scope on the counter,
because the two live on opposite ends of the association: the counter is declared on the belongs_to
and maintained from the child's create callback, which knows nothing about the parent's scope.
Here is the divergence, reproduced with has_many :comments, -> { where(published: true) } and a
counter_cache on the inverse, with one published comment and one unpublished:
comments_count column = 2
post.comments.size = 2
post.comments.count = 1
post.comments.to_a.size = 1
count runs SELECT COUNT(*) ... WHERE published = 1 and is right. size reads the column and is
wrong, and the reason is HasManyAssociation#count_records:
def count_records
count = if reflection.has_active_cached_counter?
owner.read_attribute(reflection.counter_cache_column).to_i
else
scope.count(:all)
end
The branch tests whether a counter exists, never whether the association is scoped. So a template
that renders post.comments.size next to post.comments.each prints a number that does not match
the list under it, and the two lines are four characters apart. Nothing raises, nothing logs, and
the specs pass as long as every fixture comment is published.
Counting across a join table
The second question is has_many :through, and the honest answer has two halves. The counter on the
join model works fine: Tagging declaring belongs_to :post, counter_cache: :taggings_count counts
taggings, correctly, because a tagging is a row and rows are what the callback counts.
Putting the option on the through association itself is the trap. has_many :tags, through:
:taggings, counter_cache: :tags_count raises nothing at boot, and then:
tags_count after two tags = 0
taggings_count = 2
post.tags.size = 0
post.taggings.size = 2
Two tags in the table, and post.tags.size reports zero. The counter_cache on a has_many was
only ever meant to name a column you customised on the belongs_to side, as the documentation says:
"You only need this option, when you customized the name of your :counter_cache on the belongs_to
association." Nothing in the through association writes tags_count. But has_cached_counter?
returns true for it, so size reads the column, and the column is the migration default forever.
A wrong count is recoverable. A count that is structurally zero and silent is the worst failure mode in this post, and it is produced by an option the association accepts without complaint.
The lock_version nobody asked to bump
The third case is not about the count at all. counter_cache maintains the column with
increment_counter, which reaches Relation#update_all, and update_all has a branch most people
have never read:
if updates.is_a?(Hash)
if model.locking_enabled? &&
!updates.key?(model.locking_column) &&
!updates.key?(model.locking_column.to_sym)
attr = table[model.locking_column]
updates[attr.name] = _increment_attribute(attr)
end
locking_enabled? is true for any model with a lock_version column. So creating one child of an
optimistically locked parent emits this:
UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) + 1,
"lock_version" = COALESCE("posts"."lock_version", 0) + 1
WHERE "posts"."id" = 1
Read that as a product decision rather than as SQL. Every child created invalidates every open edit
form on the parent. An admin who opened the parent, went to lunch and pressed Save gets a
StaleObjectError because somebody else left a comment while the form sat there. The counter and
the lock are both doing their jobs, and together they produce a rule nobody wrote down.
This site has one optimistically locked model, QuizAnswer, whose schema carries
lock_version :integer default(0), not null because the entries are edited in an admin form where
two tabs open on the same row is a normal accident. A view counter on that row is a write that
happens thousands of times more often than an edit. Wiring it through anything that goes near
update_all(Hash) would mean a page view invalidating an editor's form.
The one line the quiz page uses instead
QuizAnswersController#show counts a view in a single statement, with the reasoning kept next to it:
# Atomic SQL increment: never touches updated_at or lock_version (do NOT use increment!,
# and increment_counter would bump lock_version on this optimistically locked model).
QuizAnswer.where(id: @quiz_answer.id).update_all("views_count = views_count + 1") unless bot_request?
The argument of the String is the entire mechanism. update_all takes the Hash branch quoted above
only when it is given a Hash; a String goes to sanitize_sql_for_assignment and is emitted as
written:
UPDATE "quiz_answers" SET views_count = views_count + 1 WHERE "quiz_answers"."id" = ?
No lock_version, no updated_at, no instantiated record, no callbacks, one round trip. The
increment happens inside the database, so two requests landing in the same millisecond produce two
increments rather than one: the read and the write are one statement, and the row is locked for its
duration by the engine rather than by anything in Ruby.
The cost is real and worth naming. A String body is a raw SQL fragment, so it is a place where an
interpolated variable becomes an injection. views_count = views_count + 1 has nothing
interpolated into it, which is why the constant string is safe and why an increment by a
user-supplied amount would have to go back through a bound parameter.
increment!, increment_counter, update_all
Three methods increment a column in Rails 8.1, and the differences are not the ones folklore describes.
Model.increment_counter(:views_count, id) is a one line wrapper over update_counters, which
builds a Hash and calls update_all. Atomic in SQL, and it takes the locking branch. It also
accepts touch: true, which adds updated_at to the same statement.
record.increment!(:views_count) needs the record instantiated first, which is a SELECT you may
not otherwise need, and then:
increment(attribute, by)
change = public_send(attribute) - (public_send(:"#{attribute}_in_database") || 0)
self.class.update_counters(id, attribute => change, touch: touch)
The delta is computed in Ruby. On a clean record that delta is 1 and the SQL is the same atomic
COALESCE(views_count, 0) + 1, so the "lost update" story you have heard about increment! is no
longer the failure. The failure is the line above it: the delta is the difference between the
in-memory attribute and what was loaded, so any unsaved change to that attribute rides along. A
record loaded with views_count = 5, assigned views_count = 50 by some earlier code, then passed
to increment!, sent a delta of 46 and left 51 in the database. The method writes your local
guess, expressed as an increment.
relation.update_all("...") is the only one of the three that neither loads the record nor touches
the locking column. Fewest moving parts, least magic, and the one you can read in the controller
without opening the framework.
Why updated_at stays where it is
updated_at on a published row is not a timestamp, on this site it is a search engine input. The
sitemap template prints it directly:
<lastmod><%= qa.updated_at.iso8601 %></lastmod>
A view counter that touched updated_at would rewrite the <lastmod> of every quiz URL on every
page view, which tells Google that a page changed when only its popularity did. The signal is worth
something precisely because it is rare, and a counter is the highest frequency write on the whole
row. So the increment leaves the column alone, and the unless bot_request? guard on the same line
exists so the number means readers rather than crawlers, using the shared BotFiltered test that
also excludes Turbo's hover prefetches.
The opposite choice is defensible when the row means something else. Which brings up the other counter on this site.
Counting a row that does not exist yet
Yield articles are Markdown files. There is no yield_articles table, so there is no row to hang a
views_count on and no belongs_to anywhere to declare a counter cache on. Yield::ArticleStat
is a table whose only purpose is the counter, keyed by the article's slug, and the increment has to
create the row it increments:
sql = sanitize_sql_array([ <<~SQL, slug ])
INSERT INTO yield_article_stats (slug, views_count, created_at, updated_at)
VALUES (?, 1, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE
SET views_count = yield_article_stats.views_count + 1, updated_at = NOW()
SQL
find_or_create_by followed by an increment races twice: two requests both create, and the unique
index on slug turns the loser into an exception, and two requests both read the same count before
either writes. The ON CONFLICT form is one statement, so the database settles both, and the unique
index is what it settles them with.
Note that this one does move updated_at, on purpose. On a row that carries nothing but a slug and
a count, "last updated" can only mean "last viewed", and no sitemap reads it. Same mechanism as the
quiz counter, opposite decision about the timestamp, because the rows mean different things.
The index page then needs one number per article, which is counts_for(slugs), a single
pluck(:slug, :views_count).to_h with counts.default = 0 so articles nobody has opened report zero
without needing a row. A counter cache would have given you the same thing for free if these were
rows. They are not, so it costs a table, a migration, and three class methods.
What counter_culture buys
Counter culture is what people mean when they say this problem has a gem, and in a Gemfile it
is spelled counter_culture. It is for people who wanted the three things above. Version 3.14.0 was released
on 2026-06-27, it is tested against Ruby 3.0 to 4.0 and Rails 6.0 to 8.1, and 22.7 million downloads
say it is not an experiment. It is maintained by Magnus von Koeller.
What it adds over counter_cache, in its own examples: multiple levels of indirection,
counter_culture [:sub_category, :category], so a product increments a count on its category's
parent; dynamic column names, column_name: proc { |model| "#{model.product_type}_count" }, which is
the conditional counter the framework will not give you; updates on change, not only on create and
destroy, so a record moving between parents fixes both counters; delta_magnitude for counting
something other than 1 per row; and Product.counter_culture_fix_counts for the drift that every
counter accumulates.
The conditional counter has a detail worth reading before you adopt it. The proc alone is not enough: the fixer needs to know which SQL condition maps to which column, so you pass both.
counter_culture :category,
column_name: proc {|model| "#{model.product_type}_count" },
column_names: {
["products.product_type = ?", 'awesome'] => 'awesome_count',
["products.product_type = ?", 'sucky'] => 'sucky_count'
}
Now the condition exists twice, once in Ruby and once in SQL, and nothing checks that the two agree. That is the real price of a conditional counter cache, and it is charged by every implementation of one, including the one you would write yourself.
The call, and what would change it
Use counter_cache when you are counting all the children of a belongs_to, on a parent with no
lock_version, written only by Active Record. That is the majority of counting, it costs one option
and one migration, and post.comments.size becoming free is worth more than the purity of a hand
written increment.
Write the SQL yourself the moment any of those three stops being true. A condition on what counts, a
parent with optimistic locking, a counter whose subject is not a row, or a write path that includes
anything but Rails: one update_all with a String body is shorter than the workaround, and it says
what it does at the call site rather than three files away in a callback.
Install counter_culture when you have several conditional counters rather than one, when the count
has to travel up two associations, or when you need counter_culture_fix_counts more than you need
one fewer dependency. One conditional counter does not justify the gem. Four do.
What would change the recommendation: a counter_cache that took a scope, which has been asked for
repeatedly and would remove most of this post, or an update_all that stopped bumping
lock_version unasked, which would make increment_counter safe on locked rows and delete the
sharpest reason to hand write anything here.
The position has a cost, and it is drift. A hand written counter has no reset_counters and no
counter_culture_fix_counts behind it. If the increment is ever skipped, and unless bot_request?
means it is skipped deliberately several times a day, nothing can recompute the number, because the
source of truth was the request that already ended. That is acceptable for a view counter, where
approximately right is the whole specification, and unacceptable for anything a human reconciles.
Know which of the two you are counting before picking the method.
What this post does not cover
The LaunchKit boilerplate does not use counter_cache anywhere. No belongs_to in it declares the
option, no counter cache column exists in its schema, and its only update_all marks notifications read.
The two hand written counters quoted above live in this sales site, not in the product, which is the
honest provenance.
Also absent: counter_cache on polymorphic associations, where the column has to exist on every
possible parent table; the Redis or Solid Cache variants that buffer increments in memory and flush
them periodically, which trade durability for write volume and are a different post; and any
benchmark, because the statements above differ in what they lock and what they touch rather than in
microseconds, and those are the differences that bite.
The reproductions in this post ran on activerecord 8.1.3.1 against an in-memory SQLite database, because the statement being examined is built by Arel before any adapter sees it. This site itself runs the two counters quoted here on PostgreSQL.
Comments
No comments yet. Be the first.