Ruby & Rails

Counting on read is a bug you ship on purpose

A count in a loop is the N+1 nobody spots, because each query is fast and the logs look fine.

25 March 2026 2 min read Mohammad Aaquib Jawed

Rendering a list of twenty posts with a comment count each issues twenty count queries. None is slow. The logs show nothing alarming. The page is slower than it should be and nobody can say why.

Counter caches

A counter cache stores the count on the parent row and maintains it when children are created or destroyed. Reading it costs nothing because it is already loaded with the record.

Rails wires this up with counter_cache: true on the association and a matching column. The trade is one extra write when children change, in exchange for removing a query from every read — which is a good trade for anything read more often than written, which is most things.

Two practical notes. The column needs backfilling for existing rows, and Rails provides a reset helper for exactly that. And direct SQL that bypasses Active Record will not maintain it, which is the usual cause of counts drifting.

When the count needs a condition

Counter caches count all children. "Published comments only" is not something the built-in support handles, and the workaround of a custom column maintained by callbacks is where this pattern starts to go wrong.

For conditional counts, consider whether the condition can be a separate association with its own counter, or whether the number is better computed periodically. Hand-rolled counter maintenance across several callbacks is a reliable source of drift.

Denormalisation trades a guaranteed read cost for a possible consistency bug. Make that trade deliberately, and only where reads dominate.

The general shape

Counter caches are one instance of a broader idea: compute on write rather than on read, when reads dominate.

The same applies to a "last activity at" timestamp, a cached total, a materialised summary row. Each removes work from the hot path and adds a small amount to the cold one.

What they all share is the risk: the stored value can disagree with reality. Anything denormalised needs a way to be recomputed, and ideally a periodic check that compares stored against actual. Without that, drift is silent and permanent.

Knowing when not to

If the count is displayed once on a page nobody visits, a query is fine. If it changes constantly and is read rarely, maintaining it is pure cost.

The question is the ratio of reads to writes, and the answer is usually available from your logs rather than intuition.

All writing Reply by email