---
title: "Rails counter_cacheの使い方｜sizeとcountの違い・ずれの直し方【Rails 8.1.4実測】"
url: "https://www.issoh.co.jp/tech/details/3727/"
published: 2024-10-03
updated: 2026-11-02
categories: ["Ruby on Rails"]
publisher: "株式会社一創"
---

# Rails counter\_cacheの使い方｜sizeとcountの違い・ずれの直し方【Rails 8.1.4実測】

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別の対策](/tech/details/4262/)で一般化して扱っています。

### カラムを足さずに済む場合：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】](/tech/details/2883/)で確かめられます。

### 件数で並べ替え・絞り込みたいときのカウンタ列と索引

カウンタ列が本当に要るのは、件数を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ガイドの関連付けの章](https://railsguides.jp/association%5Fbasics.html)も同じ形を載せています。

```
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で検証】](/tech/details/5805/)で比べています。

### belongs\_to側の指定・カラム名の変更・ポリモーフィック関連

オプションを書くのは子モデルの`belongs_to`です。親の`has_many`には何も足しません。

```
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
end
```

カラム名を変えるときはシンボルで渡します。この場合だけ親の`has_many`にも同じ名前が必要で、[公式APIドキュメント](https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html)も「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](https://github.com/rails/rails/blob/7-2-stable/activerecord/CHANGELOG.md)、作者はfatkodima氏）。

```
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: { active: false }
end
```

この状態でも子の作成・削除に応じてカラムは更新され続け、`size`・`empty?`・`any?`・`none?`はカラムを読まずにデータベースへ問い合わせます。Rails本体のテスト「inactive counter cache」がこの2点を確かめています。

1. カウンタカラムを追加する（`null: false, default: 0`）。
2. `counter_cache: { active: false }`でデプロイし、以後の増減を取りこぼさない状態にする。
3. `reset_counters`をバッチで流し、既存分を埋める。
4. `{ active: false }`を外して`true`にし、読み取りをカラムへ切り替える。

カラム名も変えるなら`counter_cache: { active: false, column: :post_comments_count }`と書きます。7.2の他の変更は[ActiveRecord 7.2の変更点｜トランザクションコールバック・attributes\_for\_inspect・with\_connectionを実行して比較](/tech/details/10104/)に、夜間ジョブでバックフィルを流す構成は[Sidekiqとは？Rails 8.1での導入・リトライ・Web UI設定【Sidekiq 8.1.7】](/tech/details/7843/)にまとめました。

## カウンタが更新される契機と発行される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`を流すと、発行されたのは`DELETE`1本だけで、カウンタは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ドキュメント](https://api.rubyonrails.org/classes/ActiveRecord/Associations/CollectionProxy.html)のとおり既定の動きは`:nullify`で、子の行は消えずに`post_id`がNULLのまま残ります（`post_id`がNOT NULLなら制約違反で失敗します）。行ごと消すなら`dependent: :delete_all`か`:destroy`を指定します。親ごと消す場合は、カウンタも親行と一緒に消えるので崩れません。

一括挿入した件数が分かっているなら、全件を数え直さずに`update_counters`で差分だけ足せます。発行されるのは加算のUPDATE 1本で、IDの配列も渡せます（[CounterCacheのAPIドキュメント](https://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html)）。

```
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](https://github.com/rails/rails/blob/v8.1.4/activerecord/lib/active%5Frecord/attribute%5Fmethods.rb)で、更新対象から読み取り専用属性・カウンタ列・仮想カラムを外しています。一方、`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の行ロック](https://www.postgresql.org/docs/18/explicit-locking.html)どおりの結果で、子の保存に外部API呼び出しなどの重い処理が同居しているほど、同じ親への書き込みは直列に詰まります。この形ではcounter\_cacheを使わず、Redisのインクリメントや非同期の集計テーブルへ逃がします。

集計の形が合わない場合も対象外です。「公開済みのコメントだけ」のような条件付きの件数は、状態カラムの変化で動かないため表現できません。`has_many :through`の先も、カウンタを増減させるのは直接の子の`belongs_to`だけなので、2段以上離れた関連の件数は保てません。集計の都合でモデルが膨らんできたときの分割方法は[RailsのFat Model対策｜1500行model.rbを分割する4手法と判断基準](/tech/details/12933/)で扱っています。

## counter\_cacheとcounter\_cultureの選び分け

[counter\_culture](https://github.com/magnusvk/counter%5Fculture)（最新版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`は子を全件読み込んでから数えるので、件数だけが欲しい場面では使いません。

## 関連記事

- [N+1問題とは？発行クエリ数の実測と検出方法・ORM別の対策](/tech/details/4262/)
- [RailsのAssociation（関連付け）とは？6種類の使い分けと発行SQLを実測で解説【Rails 8.1.4】](/tech/details/2883/)
- [Active Record（アクティブレコード）とは？仕組みと発行SQL・N+1・検証の落とし穴を実行して解説【Rails 8.1.4】](/tech/details/2913/)
- [ActiveRecord 7.2の変更点｜トランザクションコールバック・attributes\_for\_inspect・with\_connectionを実行して比較](/tech/details/10104/)
- [Ridgepoleとは？Schemafileでスキーマを管理する使い方と落とし穴【v3.2.7で検証】](/tech/details/5805/)

---

出典: [Rails counter\_cacheの使い方｜sizeとcountの違い・ずれの直し方【Rails 8.1.4実測】](<https://www.issoh.co.jp/tech/details/3727/>)（株式会社一創）
