counter_cacheは、子レコードの件数を親テーブルのカラムに持たせて、件数を数えるCOUNTクエリを発行しないようにするActive Recordの標準機能です。設定は子モデルのbelongs_toに1行足すだけですが、呼び出しをcountのままにしていると1本もクエリが減りません。モデルを通さない一括削除でも、カウンタは黙ってずれます。本記事では、2026年9月24日リリースのRails 8.1.4で実際にコードを流し、発行されたSQLの本数をもとに、設定手順・既存テーブルへの後付け・ずれたときの直し方・採用を避ける場面を整理しました。
まとめ:counter_cache導入の判断と手順の要点
- 設定は2手順です。子モデルに
belongs_to :post, counter_cache: trueを書き、親テーブルにnull: false, default: 0のcomments_countを追加します。 - 一覧30件の件数表示は、
sizeで読むと31クエリから1クエリに減りました。countはカウンタがあっても31クエリのままです。 - 件数の表示だけが目的なら、
group(:post_id).countやleft_joins+COUNTでも1〜2クエリにできます。カウンタ列が要るのは、件数で並べ替え・絞り込みたいときと、親1件ずつ件数を読む画面です。 - 既存の大きなテーブルには、Rails 7.2で入った
counter_cache: { active: false }で書き込みだけ先に始め、バックフィル後に有効化します。 - ずれるのは
Comment.where(...).delete_all・insert_all・update_all・直接SQLです。直すのはreset_countersかupdate_countersで、カラムへの代入は無視されます。 - 人気投稿へのいいねのように1つの親行へ書き込みが集中する用途では、親行のロック待ちが起きるので採用しません。
件数表示のクエリ本数:counter_cacheと他の4つの書き方の比較
一覧30件で発行されたクエリの本数
投稿30件・コメント合計43件のデータで、各投稿のコメント数を表示する処理を書き方ごとに流しました。環境はRuby 4.0.6、activerecord 8.1.4、SQLiteのメモリDBで、sql.active_record通知を数えています。カウンタ列「なし」の行は、counter_cacheを宣言しない同じ形のposts・commentsに同じ件数のデータを入れて流しました。
| 書き方 | カウンタ列 | クエリ本数 |
|---|---|---|
| post.comments.count | なし | 31 |
| post.comments.size | なし | 31 |
| post.comments.count | あり | 31 |
| post.comments.size | あり | 1 |
| includes(:comments)+size | なし | 2 |
| group(:post_id).count | なし | 2 |
| left_joins+select COUNT | なし | 1 |
結論は2つです。カウンタ列があってもcountは毎回SELECT COUNT(*)を打つので、ビューやシリアライザのcountをsizeへ置き換えるところまでが導入作業になります。もう1つは、件数を表示するだけなら、カウンタ列を足さなくても1〜2クエリで済む書き方があることです。親を引くクエリごとに件数が増える現象そのものは、N+1問題とは?発行クエリ数の実測と検出方法・ORM別の対策で一般化して扱っています。
カラムを足さずに済む場合:group.countとleft_joinsの書き方
group(:post_id).countは、子テーブルを1回集計して{post_id => 件数}のハッシュを返します。絞り込まずに呼ぶと、表示が30件でも子テーブル全体を毎回集計するので、表示中の親のIDでwhereを掛けます。発行されたのは親を引く1本と、次の1本でした(INの中身は30個のプレースホルダ)。
SELECT COUNT(*) AS "count_all", "comments"."post_id" AS "comments_post_id"
FROM "comments" WHERE "comments"."post_id" IN (?, ?, ...) GROUP BY "comments"."post_id"
counts = Comment.where(post_id: @posts.map(&:id)).group(:post_id).count
@posts.each { |post| counts.fetch(post.id, 0) } # コメント0件の親はキーが無い
# 親と件数を1本で取る
Post.left_joins(:comments)
.select("posts.*, COUNT(comments.id) AS comments_n")
.group("posts.id")
includesも2クエリですが、件数を出すためだけに子レコード43件を全部メモリへ載せます。子が1親あたり数百件ある一覧では採りません。これらの書き方で足りるのは、画面に件数を出すだけの場合です。表示中のIDに絞り、子テーブルのpost_idに索引があれば、集計は表示件数分で済み、書き込み側の負担も増えません。関連付けごとのSQLの出方はRailsのAssociation(関連付け)とは?6種類の使い分けと発行SQLを実測で解説【Rails 8.1.4】で確かめられます。
件数で並べ替え・絞り込みたいときのカウンタ列と索引
カウンタ列が本当に要るのは、件数をORDER BYやWHEREに使う要件です。カウンタは普通の整数カラムなので、索引を張れば件数順のページングも1本のクエリで済みます。
add_index :posts, :comments_count
Post.order(comments_count: :desc).limit(10) # コメントが多い順
Post.where(comments_count: 10..) # 10件以上だけ
left_joins+GROUP BYで同じことをすると、全件を集計してから並べ替えるので、親テーブルが育つほどページングが重くなります。「多い順」「1件以上だけ」が要件に入った時点で、カウンタ列を足す理由は速度より機能のほうに移ります。
実装手順:カウンタカラムの追加とbelongs_toの設定
カラム追加のマイグレーションと既定カラム名の決まり方
Commentを数えるなら、親のpostsにcomments_countを足します。NULLを許すと未設定の親で表示が崩れるため、null: false, default: 0を付けます。Railsガイドの関連付けの章も同じ形を載せています。
class AddCommentsCountToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :comments_count, :integer, null: false, default: 0
end
end
既定のカラム名を、公式APIドキュメントは#{table_name}_countと書いています。実装は少し違い、Rails 8.1のreflection.rbは子モデルのクラス名を名前空間なしのスネークケースにし、複数形にして_countを付けます(Blog::Commentならcomments_count)。self.table_nameでテーブル名を変えているモデルでは両者が食い違うので、カラム名を明示してください。スキーマをマイグレーションで積むか宣言的に管理するかはRidgepoleとは?Schemafileでスキーマを管理する使い方と落とし穴【v3.2.7で検証】で比べています。
belongs_to側の指定・カラム名の変更・ポリモーフィック関連
オプションを書くのは子モデルのbelongs_toです。親のhas_manyには何も足しません。
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
カラム名を変えるときはシンボルで渡します。この場合だけ親のhas_manyにも同じ名前が必要で、公式APIドキュメントも「You only need this option, when you customized the name of your :counter_cache on the belongs_to association.」と書いています。
class Comment < ApplicationRecord
belongs_to :post, counter_cache: :post_comments_count
end
class Post < ApplicationRecord
has_many :comments, counter_cache: :post_comments_count
end
ポリモーフィック関連でも使えます。belongs_to :notable, polymorphic: true, counter_cache: trueとし、相手になるarticlesとvideosの両方にnotes_countを用意すると、Articleに1件・Videoに2件付けた結果はnotes_countがそれぞれ1と2になり、article.notes.sizeはクエリ0本でした。指定できるのはカラム名とactive:だけで、「公開済みだけ数える」といった条件は付けられません。
既存データのバックフィルとreset_countersの実行単位
カラムを足しただけでは、既にある子レコードの分は0のままです。数え直しにはreset_countersを使い、第1引数にはIDの配列も渡せます。
Post.in_batches(of: 200) do |batch|
Post.reset_counters(batch.ids, :comments)
end
ID3件で流すと、JOINとGROUP BYのCOUNTが1本、続いてIDごとのUPDATEが3本出ました。子が0件の親も0で上書きされます。集計は1本にまとまっても書き込みは対象IDの数だけ走るので、of: 200なら1バッチ200本のUPDATEです。この単位でジョブを分けてください。もう1つの前提として、reset_countersは子側のcounter_cache宣言を探して動くため、宣言を書く前のモデルでは使えません。
既存の大規模テーブルへ後付けする手順(Rails 7.2以降のactive: false)
運用中のテーブルへの後付けには順序の問題があります。カラム追加とバックフィルの間に子が増減すると、その分はどこにも記録されません。かといって先にcounter_cache: trueにすると、埋め終わるまでsizeが誤った値を返します。Rails 7.2はこの期間だけ「書くが読まない」状態にする指定を足しました(activerecord 7.2のCHANGELOG、作者はfatkodima氏)。
class Comment < ApplicationRecord
belongs_to :post, counter_cache: { active: false }
end
この状態でも子の作成・削除に応じてカラムは更新され続け、size・empty?・any?・none?はカラムを読まずにデータベースへ問い合わせます。Rails本体のテスト「inactive counter cache」がこの2点を確かめています。
- カウンタカラムを追加する(
null: false, default: 0)。 counter_cache: { active: false }でデプロイし、以後の増減を取りこぼさない状態にする。reset_countersをバッチで流し、既存分を埋める。{ active: false }を外してtrueにし、読み取りをカラムへ切り替える。
カラム名も変えるならcounter_cache: { active: false, column: :post_comments_count }と書きます。7.2の他の変更はActiveRecord 7.2の変更点|トランザクションコールバック・attributes_for_inspect・with_connectionを実行して比較に、夜間ジョブでバックフィルを流す構成はSidekiqとは?Rails 8.1での導入・リトライ・Web UI設定【Sidekiq 8.1.7】にまとめました。
カウンタが更新される契機と発行されるSQL
作成・削除・外部キーの付け替えで走る親行のUPDATE
カウンタが動くのは子の作成・削除と、外部キーの付け替えの3つです。comment.update!(post: other_post)を流すと、子のUPDATEに続いて新しい親の加算と古い親の減算が出ました。
UPDATE "comments" SET "post_id" = ?, "updated_at" = ? WHERE "comments"."id" = ?
UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) + ? WHERE "posts"."id" = ?
UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) - ? WHERE "posts"."id" = ?
値を読んで1を足して書き戻すのではなく、データベース側で加算しているので、同時に子が作られても数え落としは起きません。一方でcomment.update!(published: true)は子のUPDATE 1本だけで、親には何も起きませんでした。状態カラムの変化では件数は動かない、ということです。
メモリ上の親オブジェクトに古い件数が残るケース
カウンタはDBでは正しくても、読み込み済みの親オブジェクトの値が古いまま残ることがあります。Comment.create!(post: post)のように親オブジェクトを渡すと、Railsはメモリ上のcomments_countも2から3へ進めました。ところがComment.create!(post_id: post.id)とIDで渡すと、DBは2になっているのに、手元のpost.comments_countとpost.comments.sizeは1のままでした。reloadすると2になります。作成直後に同じオブジェクトで件数を表示する処理では、親オブジェクトを渡すかreloadを挟んでください。
キャッシュを読むメソッドと必ずクエリを打つメソッド
カウンタを読むメソッドは4つで、countだけは常にSQLを発行します。Rails本体のテスト「active counter cache」も同じ切り分けを確認しています。
| 呼び出し | active: true | active: false | 返す値 |
|---|---|---|---|
| size | クエリ0 | クエリ1 | カラム値 |
| empty? | クエリ0 | クエリ1 | カラム値で判定 |
| any? | クエリ0 | クエリ1 | カラム値で判定 |
| none? | クエリ0 | クエリ1 | カラム値で判定 |
| count | クエリ1 | クエリ1 | 実データの集計 |
| length | 全件ロード | 全件ロード | 配列の要素数 |
逆に、カウンタのずれを疑って実数を確かめたいときはcountを明示します。lengthは子を全件読んでから数えるので、件数だけが欲しい場面では最も重い選択です。
カウンタがずれる操作と、復旧までの手順
delete_all・insert_allでずれる様子と、update_countersでの補正
カウンタの増減はモデル単位の保存・削除処理(save・destroyの内部)に組み込まれているので、モデルを通さない一括操作では動きません。コメント3件の投稿でComment.where(post_id: post.id).delete_allを流すと、発行されたのはDELETE1本だけで、カウンタは3、実数は0になりました。このときpost.comments.sizeは3を返し、exists?はfalseです。同じくComment.insert_allで3件足すと、カウンタは2のまま実数は5になりました。ずれる操作は次のとおりです。
Comment.where(...).delete_allのような、クラスやリレーション経由の一括削除update_all(post_id: 9)による外部キーの一括付け替えinsert_all・upsert_allによる一括挿入- DBクライアントやマイグレーションからの直接SQL
関連経由のpost.comments.delete_allは別で、子のpost_idをNULLにするUPDATEのあとにカウンタを減算するUPDATEが続き、カウンタも実数も0になりました。ただしhas_manyにdependent:を付けていないと、CollectionProxyのAPIドキュメントのとおり既定の動きは:nullifyで、子の行は消えずにpost_idがNULLのまま残ります(post_idがNOT NULLなら制約違反で失敗します)。行ごと消すならdependent: :delete_allか:destroyを指定します。親ごと消す場合は、カウンタも親行と一緒に消えるので崩れません。
一括挿入した件数が分かっているなら、全件を数え直さずにupdate_countersで差分だけ足せます。発行されるのは加算のUPDATE 1本で、IDの配列も渡せます(CounterCacheのAPIドキュメント)。
Comment.insert_all(rows) # カウンタは動かない
Post.update_counters(post.id, comments_count: rows.size)
# Post.increment_counter(:comments_count, post.id, by: rows.size) # 1カラムなら上の行の代わりにこれでも同じ
カウンタ列への代入が無視される挙動と、7.1での実装移動
ずれに気付いてカラムへ直接代入しても、値は書き込まれません。post.update(comments_count: 42)を流すと、発行されたのはUPDATE "posts" SET "updated_at" = ?だけで、例外も出ず、カウンタは元の値のままでした。updated_atだけが進む点に注意してください。
理由はattribute_methods.rbのattributes_for_updateで、更新対象から読み取り専用属性・カウンタ列・仮想カラムを外しています。一方、timestamp.rbのshould_record_timestamps?は変更があるかどうかだけを見るので、comments_countの変更を検知してupdated_atは付け足されます。この「更新時だけ落ちる」挙動は7.0以前からありますが、7.1でカウンタ列の管理がattr_readonlyから_counter_cache_columnsへ移りました。そのため7.1で入ったActiveRecord::ReadonlyAttributeErrorはカウンタ列では発生しません。ただしAPIドキュメントの注記どおりattr_readonly :comments_countを足したモデルでは、load_defaults 7.1以降の既定(raise_on_assign_to_attr_readonly = true)で代入時にこの例外になります。新規作成時のPost.create!(comments_count: 5)は除外されず、INSERTに5が入ります。
reset_countersでの復旧とtouchの副作用
原因を問わず正しい値に戻せるのがreset_countersです。実データを数えて書き戻します。
Post.reset_counters(5, :comments)
Post.reset_counters([10, 15], :comments, touch: true)
touch: trueを付けるとupdated_atも更新され、キャッシュキーにupdated_atを使っているフラグメントキャッシュを破棄できます。困るのは、更新順のソートや同期のカーソルにupdated_atを使っている場合です。touch: :counted_atとカラム名を渡しても、発行されたSQLはSET "comments_count" = ?, "updated_at" = ?, "counted_at" = ?で、updated_atも一緒に動きました。updated_atを動かしたくないならtouch:を付けずに再集計し、目印はupdate_all(counted_at: Time.current)で別に付けます。一括削除を運用に残すなら、夜間に対象範囲だけ流す設計にし、全件の再集計を毎晩回す構成は避けます。テーブルが育つほど実行時間が伸びるためです。
counter_cacheを採用すべきでない場面:親行のロック待ち
最も避けたいのは、同じ親行へ子の作成が集中する用途です。人気投稿へのいいねやライブ配信のコメントでは、子の保存と同じトランザクションの中で、全員が同じ親行のUPDATEを取り合います。
PostgreSQL 18.6で、Railsが出すのと同じ「子のINSERT+親のUPDATE」を2つのセッションから流しました。セッションAがpost 1を更新したまま3秒コミットしない間に、セッションBも同じpost 1へコメントを足すと、Bの完了まで2.44秒かかりました。待ちの種類はpg_stat_activityでLock / transactionidです。Bの宛先を別のpost 2に変えると0.2秒で終わります。UPDATEは対象行をコミットまでロックし続けるというPostgreSQLの行ロックどおりの結果で、子の保存に外部API呼び出しなどの重い処理が同居しているほど、同じ親への書き込みは直列に詰まります。この形ではcounter_cacheを使わず、Redisのインクリメントや非同期の集計テーブルへ逃がします。
集計の形が合わない場合も対象外です。「公開済みのコメントだけ」のような条件付きの件数は、状態カラムの変化で動かないため表現できません。has_many :throughの先も、カウンタを増減させるのは直接の子のbelongs_toだけなので、2段以上離れた関連の件数は保てません。集計の都合でモデルが膨らんできたときの分割方法はRailsのFat Model対策|1500行model.rbを分割する4手法と判断基準で扱っています。
counter_cacheとcounter_cultureの選び分け
counter_culture(最新版3.14.0、2026年6月27日リリース)は、標準機能の制約のうち条件付き集計・多段の関連・合計値を埋めるgemです。
| 観点 | counter_cache | counter_culture 3.14.0 |
|---|---|---|
| 導入 | 標準機能 | gem追加 |
| 宣言場所 | belongs_to | counter_culture |
| 条件付き集計 | 不可 | 可 |
| 多段の関連 | 不可 | 可 |
| 合計値の保持 | 不可 | delta_column |
| コミット後に更新 | 不可 | execute_after_commit |
| sizeでキャッシュを読む | 読む | 読まない |
| 再集計 | reset_counters | counter_culture_fix_counts |
見落としやすいのがsizeの行です。READMEは冒頭で「counter_culture does not currently change the behavior of the `.size` method on ActiveRecord associations」と書いており、post.comments.sizeはCOUNTを打ちます。キャッシュを読むにはpost.comments_countとカラム名を直接参照する形へ書き換えが要ります。
READMEがデッドロック対策として挙げるcounter_culture :post, execute_after_commit: trueは、カウンタ更新をトランザクションの外へ出す指定です。親行のロックを子の保存トランザクションの間ずっと持ち続ける状態も避けられます。READMEはRails 7.2以降なら標準のActiveRecord.after_all_transactions_commitを使い追加のgemは要らないとしています。その代わり、子の保存とカウンタ更新が同じトランザクションではなくなり、整合性の保証を手放します。複数の子をまとめて作るなら、CounterCulture.aggregate_counter_updatesで囲むと親1件あたりのUPDATEが1本に集約されます。
選ぶ基準は単純です。関連の全件を1段で数えるだけなら標準のcounter_cacheを使い、依存を増やしません。条件を付けたい、親の親を数えたい、ロック待ちをコミット後の更新で逃がしたい、のどれかが出た時点でcounter_cultureへ移ります。
よくある質問
counter_cacheとcounter_cultureはどちらを使うべきですか?
関連の全件数を1段で数えるだけならcounter_cacheです。標準機能で依存が増えず、設定もbelongs_toの1行で済みます。「公開済みだけ」のような条件付き集計、has_many :through越しの集計、金額などの合計値が要るならcounter_cultureを選びます。移行するときはbelongs_toのcounter_cacheを先に外し、counter_cultureを宣言してからcounter_culture_fix_countsを流します。両方を残すと同じカラムを二重に加算します。
counter_cacheが更新されないのはなぜですか?
多くはモデルを通さない一括操作です。Comment.where(post_id: 5).delete_allやinsert_all・update_all・直接SQLはコールバックを実行しないので、カウンタだけが取り残されます。関連経由のpost.comments.delete_allではカウンタが維持されます(dependent:未指定なら子は削除されず外部キーがNULLになる点は別に注意)。どちらで消したかを切り分けてください。もう1つは、IDで子を作ったときに手元の親オブジェクトの値が古いまま残るケースで、reloadで解消します。復旧はPost.reset_counters(post_id, :comments)で行い、カラムへの代入は無視されるので使えません。
counter_cacheのカラム名は変更できますか?
できます。belongs_to :post, counter_cache: :post_comments_countのようにシンボルで指定します。この場合だけ、親側のhas_many :comments, counter_cache: :post_comments_countにも同じ名前が必要です。1つの親に複数種類の子を数えさせるときも、この指定でカラムを分けます。
既存データが大量にあるテーブルにも後から追加できますか?
Rails 7.2以降ならcounter_cache: { active: false }を使います。カラムの更新だけを先に始め、sizeなどはデータベースへ問い合わせたまま保てるので、バックフィル中に誤った値を返しません。埋め終わったら{ active: false }を外します。7.1以前には同じ機能が無いので、DBトリガーか子モデルのコールバックで暫定的に同期させる手当てが要ります。
size と count はどちらを呼べばキャッシュが効きますか?
sizeです。一覧30件の実測では、カウンタ列があってもcountは31クエリ、sizeは1クエリでした。empty?・any?・none?もカウンタを読みます。lengthは子を全件読み込んでから数えるので、件数だけが欲しい場面では使いません。