counter_culture deliberately leaves .size running a COUNT query
Turbo-charged counter caches for your Rails app.
At a glance
- What is it?
- counter_culture is a Rails gem that keeps denormalized counters current through updates, multi-level relations and dynamic column names. It does not make .size cheap, so the cached attribute has to be read by name instead.
- Who is it for?
- counter_culture suits a Rails application where related records change often enough that a standard counter cache drifts out of sync, and where you need totals or per-type counts rather than plain tallies. It does not suit a project that wants .size to become cheap, because the gem explicitly does not change that method.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 95 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The counter moves when the value changes, not only on create and destroy
Rails ships its own counter cache, and counter_culture positions itself against it on four points. The first is the one that matters most in production: the built-in counter updates when records are created and destroyed, and counter_culture also updates when the counted value changes. If a record moves from one category to another, or a membership switches its parent, the built-in counter is left stale and the application has to repair it. Everything else in the gem builds on that guarantee. The second point is multi-level relations, so a product can count toward its sub-category and its category. The third is dynamic column names, which split one counter into separate columns by object type. The fourth is that a counter can hold either a running count or a running total. The gem is tested against Ruby 3.0 through 3.3 and 4.0, and against the latest patch releases of Rails 6.0, 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1, so version coverage is broad and stated in the README rather than inferred.
Reading the count through the association still costs a query
The single most important caveat is at the top of the README, and it is easy to skim. Unlike the built-in counter caches, counter_culture does not change the behavior of the .size method on ActiveRecord associations. The consequence is concrete:
product.categories.size # => will lead to a SELECT COUNT(*) query
product.categories_count # => will use counter cache without querySo the cache is maintained correctly and the association reader still ignores it. Any view, serializer or API response that calls .size pays for a count query anyway, which means the performance win depends entirely on your application reading the attribute by name. That is the migration work nobody mentions: you have to find every place that reaches for the collection and change it to the counter attribute. It is also why the gem cannot simply be dropped into an existing codebase and expected to remove queries without code review.
Adding the gem and creating the column
Installation is a Gemfile entry plus bundle install:
gem 'counter_culture', '~> 3.2'The version constraint is a pessimistic one on the 3.2 series. The next step is the schema, and it has a hard requirement: the counter column must be NOT NULL with a default of zero, or the gem does not work correctly. Rather than hand-writing that, the gem ships a generator:
rails generate counter_culture Category products_countThat produces a migration containing the equivalent of an integer column with null set to false and a default of 0, added to the categories table. One thing the generator cannot do for you is fill existing rows. If you are adding a counter to a table that already holds data, you have to add code to the generated migration to populate the values manually, and the README links that to its manual population section rather than pretending the generator handles it.
One declaration per level, not one declaration per chain
The multi-level case is where a first reading gets the model wrong. Given a Product that belongs to a SubCategory, which in turn belongs to a Category, a single declaration on Product walks the whole chain and keeps only the far end current:
class Product < ActiveRecord::Base
belongs_to :sub_category
counter_culture [:sub_category, :category]
endThat maintains products_count on categories, and the README notes it works with any number of levels. If you also want a count on the sub_category, you do not extend the array. You add a second, separate declaration on its own, counter_culture [:sub_category], so the same model carries two independent caches. Each declaration writes to its own column, and each one has to exist for the level you care about. Multi-level arrays also compose with the customization options, since column_name can be used with a chain.
Dynamic column names move the problem to the fix-counts path
A proc in the column_name position lets one association feed several columns. The example builds the name from the record itself, so a product with a product_type attribute writes to awesome_count or sucky_count depending on its type. The conditional form goes further and returns either a column name or nil, so only records where the predicate returns true contribute at all. That is a compact way to keep, say, special_count without filtering the association. The cost lands on maintenance. Because the set of possible columns is now derived at runtime, the fixed path that recomputes counters cannot know which columns exist, and the README tells you to supply the column_names configuration when you combine a conditional counter with counter_culture_fix_counts. Recomputing counts is exactly what you need when migrating existing data, and it is exactly the path that a dynamic column name makes ambiguous.
Totals use delta_magnitude, and a field value uses delta_column
A counter column does not have to hold a count. delta_magnitude multiplies each event, and it accepts either a static number or a proc. With a proc returning 2 for one product type and 1 for the other, a category's weight column moves by 2 for an awesome product and by 1 for the rest; with the static form, delta_magnitude: 3, adding a product raises the weight by 3 and deleting one lowers it by 3. For a running total of a real field, the option is different: :delta_column names a field on the counted object, and the counter changes by that field's value instead of by exactly 1. The README's example uses weight_ounces on the product model to keep a running total in the category's product_weight_ounces field. The two options are easy to confuse, so pick by asking whether the increment is a property of the relationship or of the record's data.
Where counter_culture is the wrong tool
The clearest wrong case is a code change that reads through the association. Since .size is untouched, a team expecting the gem to make collection reads cheap will not see the improvement, and any code path that keeps calling .size keeps paying for SELECT COUNT(*) while also paying for the write-side updates the gem adds. The second wrong case is a schema you cannot constrain. The NOT NULL and default zero requirement is strict, so a table where a count column is legitimately unknown cannot use the gem as-is. The third is derived state with many sources. Because the cache is maintained by callbacks on the counted model, bulk SQL updates, delete_all, or an UPDATE issued outside ActiveRecord do not fire them, and the count drifts until someone runs the fix path. The fourth is performance under write-heavy workloads, where every qualifying write now costs an additional column update on the parent row, which can turn into lock contention on a hot parent. The gem gives you skip_counter_culture_updates for a backfill block, which is the right tool for a maintenance window and the wrong tool for a permanent fix.
Editorial conclusion
counter_culture suits a Rails application where related records change often enough that a standard counter cache drifts out of sync, and where you need totals or per-type counts rather than plain tallies. It does not suit a project that wants .size to become cheap, because the gem explicitly does not change that method. Before adopting, add the columns as NOT NULL with a default of zero, populate them by hand for existing data, and read the counter attribute directly rather than through the association.
Frequently asked questions
How do I install counter_culture in a Rails app?
Add gem 'counter_culture', '~> 3.2' to the Gemfile and run bundle install. Then create the counter column with rails generate counter_culture Category products_count.
Does counter_culture make association .size fast?
No. The README states it does not change .size on ActiveRecord associations, so product.categories.size still runs a SELECT COUNT(*) query. Read the counter attribute directly, for example product.categories_count.
What are the requirements for the counter column?
It must be NOT NULL and have a default of zero, otherwise the gem does not work correctly. For a table that already holds data you must also add code to the generated migration to populate the values.
How do I keep a counter on every level of a hierarchy?
Declare counter_culture separately for each level, for example counter_culture [:sub_category, :category] followed by counter_culture [:sub_category] on the same model.
What is the difference between delta_magnitude and delta_column?
delta_magnitude multiplies each event and accepts a static number or a proc, turning the counter into a weighted score. The :delta_column option changes the counter by the value of a named field on the counted object, which is how a running total is kept.
Can I pause counter updates during a backfill?
Wrap the work in skip_counter_culture_updates on the model, as in Review.skip_counter_culture_updates do ... end. The cached value stays unchanged for the duration of the block.