Custom Counter Cache is a Rails gem for counter caches whose value comes from any block you give it, kept up to date by callbacks on any number of models:
- Any value — the block recomputes the counter from scratch, so it can count with any
conditions (
user.articles.where(state: 'published').count), sum, or anything else. - Any number of triggers — each model that can change the counter declares it, including polymorphic associations, custom and composite keys, and grandparent owners.
- Three places to keep it — a column, a shared counters table (no migration per counter), or a cache store such as Redis or Memcached.
- Correct under concurrency — recounts run after the transaction commits, once per owner and counter, under a short row lock, so concurrent saves can't leave a stale count.
- Bulk tools — batch or skip recounts during imports, and repair drift with
recount_counter_cachesor a rake task.
Because every recount recomputes the value, a late, repeated or batched recount still ends at the
right number. Counter caches that add and subtract 1 (Rails' built-in counter_cache,
counter_culture) are faster per update but drift after bulk operations, races or bugs; this gem
trades a count query per recount for that property.
Requires Ruby 3.3 or newer and Rails (Active Record) 8.0 or newer. CI runs Ruby 3.3, 3.4 and 4.0 against Rails 8.0 and 8.1, the versions that are not yet end-of-life.
Add the following to your Gemfile:
gem 'custom_counter_cache'Then include the module in the models that define or update counters, usually all of them:
class ApplicationRecord < ActiveRecord::Base
include CustomCounterCache::Model
primary_abstract_class
endThe block calculates the counter's value from its owner. It's called whenever a model that
declares update_counter_cache (see below) changes.
class User < ApplicationRecord
has_many :articles
define_counter_cache :articles_count do |user|
user.articles.where(state: 'published').count
end
endThis defines user.articles_count, user.articles_count= and user.update_articles_count, which
recounts immediately.
Pass touch: true (or a column name, e.g. touch: :counted_at) to also set that timestamp,
updated_at for true, on every recount. A column counter is written in the same UPDATE, and no
callbacks run.
Where the value is kept depends on the counter: a column of the same name, a row in a shared
counters table, or a cache store (store: :cache). See Choosing storage.
Declare which models change the counter with update_counter_cache, naming a belongs_to
association and the counter on its owner:
class Article < ApplicationRecord
belongs_to :user
update_counter_cache :user, :articles_count, on_change: [:state]
endThis registers after_create, after_update and after_destroy callbacks (plus after_restore for Paranoia, see Soft deletes). Any number of models can update the same counter. When a record moves to another owner, both the old and the new owner are recounted.
| Option | Effect |
|---|---|
on_change: [:state] |
Recount on update only when a listed attribute changed. The association's key columns (plus the type column for a polymorphic one) are always watched, so moving the record to another owner recounts both. Create and destroy always recount. Prefer it to :if for plain attribute checks. |
if: / unless: |
Limit when the create and update callbacks run, e.g. if: -> { saved_change_to_state? }. They don't apply to destroy or restore: those have no saved changes, and an extra recount is never wrong. Combined with on_change:, both must allow the update. |
only: / except: |
Limit the events, any of :create, :update, :destroy and :restore, e.g. only: [:create, :destroy] or except: [:destroy]. |
prepend: true |
Prepend the callbacks instead of appending them. |
recount: :later |
Recount in a background job instead of right after commit. See Background jobs. |
The callbacks run after the record is saved, so in an :if use saved_change_to_state? rather
than state_changed?, which is always false by then.
Pass a path of belongs_to associations to recount an owner further up. For a comments_count
on User across all of a user's articles:
class User < ApplicationRecord
has_many :articles
has_many :comments, through: :articles
define_counter_cache(:comments_count) { |user| user.comments.count }
end
class Comment < ApplicationRecord
belongs_to :article
update_counter_cache [:article, :user], :comments_count
end
class Article < ApplicationRecord
belongs_to :user
update_counter_cache :user, :comments_count # an article moving users changes both counts
endMoving a comment recounts the old and the new user, once if they're the same user. The second
declaration is needed because an Article changing user also changes both users' counts, and only
Article's own callbacks see that. A step after a polymorphic one is skipped for records whose
class has no such association; any other missing or non-belongs_to step raises ArgumentError.
Discard soft-deletes with an ordinary update, so the callbacks already fire. Filter with .kept
in the counter block, and if you use on_change:, include :discarded_at, or discarding won't
recount:
define_counter_cache(:pages_count) { |notebook| notebook.pages.kept.count }
update_counter_cache :notebook, :pages_count, on_change: [:state, :discarded_at]Paranoia's destroy runs the destroy callbacks, and restore is recounted too, provided
acts_as_paranoid is declared before update_counter_cache. Skip it with except: [:restore]
(only: accepts :restore too).
class Parcel < ApplicationRecord
acts_as_paranoid
belongs_to :crate
update_counter_cache :crate, :parcels_count
endEvery triggering save recounts its owner. For bulk work, batch the recounts so each owner and counter is recounted once, when the block ends (or when its transaction commits):
CustomCounterCache.batch do
rows.each { |row| article.comments.create!(row) } # one recount of article.comments_count
endBatches nest (the outermost one flushes) and still flush if the block raises, since a recount reflects whatever is in the database. To skip recounts entirely, e.g. for an import you'll recount afterwards:
CustomCounterCache.skip { import_comments }Both are per thread (and per fiber).
Recounts triggered by these callbacks run after the saving transaction commits, once per owner and counter per transaction, and not at all if it rolls back. Each takes a short lock on the owner's row while it counts, so two saves committing at once can't leave a stale value: the later recount always counts after the earlier commit. (Counting inside the saving transaction can't see a concurrent save's uncommitted child, so one of them would overwrite the other.)
The counter therefore changes when the transaction commits, not when the child is saved. Inside a transaction, call the update method yourself if you need the new value straight away:
Article.transaction do
user.articles.create!(attrs)
user.update_articles_count # recounts now; update_* and recount_counter_caches never wait
endIf a recount raises after commit, the save has already succeeded and the count can be rebuilt, so
the error doesn't propagate to the caller or stop the other recounts queued for that commit. It's
passed to Rails.error.unexpected, which raises in development and test (with
consider_all_requests_local, as by default) and in production reports it to your error tracker
with the owner and counter in the context. It's also logged.
Calling update_<name> or recount_counter_caches yourself raises as usual, and so does
CustomCounterCache::RecountJob, so your queue's retries apply.
To recount in a background job instead, enqueued after commit:
update_counter_cache :user, :articles_count, recount: :laterThis enqueues CustomCounterCache::RecountJob (Active Job) once per owner and counter per
transaction. To pick a queue:
CustomCounterCache::RecountJob.queue_as :low| Storage | How | Trade-offs |
|---|---|---|
| Column | Add a column with the counter's name. | Fastest to read, and you can sort and filter by it in SQL. Needs a migration per counter. |
| Counters table | Used automatically when there's no column. See The counters table. | One shared table, no migration per counter; preload with includes(:counters). Whole numbers only. |
| Cache | define_counter_cache :x, store: :cache, expires_in: 12.hours |
Kept in CustomCounterCache.cache_store (defaults to Rails.cache: Redis, Memcached, Solid Cache...). Reads compute on a miss; a child change deletes the key after commit instead of recounting, so writes are cheap and never lock the owner's row. Destroying the owner deletes its keys. Not visible to SQL, and a delete racing a concurrent read can leave a stale value for up to expires_in. |
Column or counters table is decided when the counter is read or written, not when the model loads, so defining counters never touches the database, and a column added later is picked up.
To use a column, add one:
def change
add_column :users, :articles_count, :integer, default: 0, null: false
endTo store counters in a single shared table, use this migration:
create_table :counters do |t|
t.references :countable, polymorphic: true
t.string :key, null: false
t.integer :value, null: false, default: 0
t.timestamps
end
add_index :counters, [:countable_id, :countable_type, :key], unique: trueand this model:
class Counter < ActiveRecord::Base
belongs_to :countable, polymorphic: true
validates :countable, presence: true
endTo use a different model name (for example if Counter is already taken), set
CustomCounterCache.counter_class_name (see Configuration).
When a record is destroyed, its counter rows are removed with a single DELETE, without loading
them or running Counter's callbacks. Don't add dependent: :destroy to the belongs_to above: on
a belongs_to it means "destroying this Counter also destroys its owner".
The table holds whole numbers. A block result of nil is stored as 0, and whole values such as
4.0 or BigDecimal('4') are converted, but a fractional result such as an average raises
ArgumentError rather than being truncated: give that counter a column of a suitable type (e.g.
decimal) or use store: :cache.
countable_id must match your models' primary key type. For string or UUID keys, use
t.references :countable, polymorphic: true, type: :uuid (or :string).
A model with a composite primary key can't use this table, since countable_id holds one value:
give its counters a column or store: :cache (it raises ArgumentError otherwise). Composite keys
work everywhere else, including composite foreign keys on the belongs_to side.
To backfill your counters, or repair them later, recount every record from the console or a migration:
User.recount_counter_cachesor only some counters and records:
User.recount_counter_caches(:articles_count, scope: User.where(id: 1..1000), batch_size: 500)It returns the number of records processed. The same is available as a rake task, where
COUNTERS (comma-separated, default all) and BATCH_SIZE (default 1000) are optional:
bin/rails custom_counter_cache:recount MODEL=User COUNTERS=articles_count
Callbacks can't see update_all, delete_all, insert_all or SQL imports, so counts drift after
them. Run the recount periodically, or after an import, to repair that. It calls update_<name>
directly, so it also works inside CustomCounterCache.skip { }.
In a migration that also adds the column, call User.reset_column_information first so the
backfill writes to the new column.
Set these in an initializer, before your models load:
# config/initializers/custom_counter_cache.rb
# The model behind the counters table. Default: 'Counter'.
CustomCounterCache.counter_class_name = 'CounterCache'
# The store for store: :cache counters. Default: Rails.cache.
CustomCounterCache.cache_store = ActiveSupport::Cache::MemoryStore.newRails' transactional tests commit each save within the test transaction, so recounts happen after
each save as they do in production; inside an explicit transaction block they wait for its end.
A recount that raises fails the test, since Rails' error reporter raises in the test environment.
With recount: :later, run the enqueued jobs (e.g. perform_enqueued_jobs) before asserting on
the counter.