LaunchKit
← All posts
· 15 min read · by The LaunchKit team · 2 views

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.

The same plus one written three ways and compared on four axes: increment_counter, record.increment! and update_all given a String. The rows ask where the arithmetic happens, whether updated_at moves, whether lock_version is bumped, and whether a concurrent request can lose the count, and the three methods answer differently on every row but the last.

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.