Rails Counter Cache Gotcha

Introduction
Today I was attempting to use Rails’ counter cache feature to save on expensive queries. My model’s association is a has_many :through. I ran into issues on my first attempt but eventually came across the answer after beating my head against a wall for quite some time.
TL;DR: If you use a custom counter_cache column on your belongs_to, duplicate that on the has_many associations as well.
Models
Let’s work with a standard, easy to understand, has_many :through models:
class Physician < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments
end
class Appointment < ApplicationRecord
  belongs_to :physician
  belongs_to :patient
end
class Patient < ApplicationRecord
  has_many :appointments
  has_many :physicians, through: :appointments
end
Counter Cache, Attempt #1
Now let’s implement counter cache on these models with custom column names. On the belongs_to side of the association, we’ll set up our counter caches for physicians and patients.
class Appointment < ApplicationRecord
  belongs_to :physician, counter_cache: :patients_size
  belongs_to :patient, counter_cache: :physicians_size
end
Migration
Now, we need to add these counter cache columns with a migration:
class AddRenderingCountsToCampaign < ActiveRecord::Migration[6.0]
  def change
    add_column :physicians, :patients_size, :integer, default: 0, null: false
    add_column :patients, :physicians_size, :integer, default: 0, null: false
    reversible do |dir|
      dir.up do
          Physician.find_each { |p| Physician.reset_counters(p, :patients) }
          Patient.find_each { |p| Patient.reset_counters(p.id, :physicians) }
      end
    end
  end
end
This migration will set up our database with these new columns and set values for existing Physicians and Patients via reset_counters.
Be sure to run rails db:migrate
Test It
After that, we can now test it in the Rails console.
1] pry(main)> p = Physician.last
  Physician Load (4.5ms)  ...
=> #<Physician:0x00007fd7aa4acb60. ..>
[2] pry(main)> p.patients.size
   (5.6ms)  SELECT COUNT(*) FROM ...
=> 1
Hmm, from my understanding, the size method should be using our counter cache, not issuing a SQL COUNT query in the database. What’s going on here?
Digging into the Documentation
So, it’s not working. Querying the web, it’s difficult to find help with this issue. That is until you dig into the Rails docs, specifically the has_many method
Scrolling through the options for has_many, you’ll come across :counter_cache and it says: “This option can be used to configure a custom named :counter_cache. You only need this option, when you customized the name of your :counter_cache on the #belongs_to association.”
Eureka! We customized the name of our :counter_cache, so let’s give this a try!
Attempt #2
Let’s revisit the models and make this modification.
class Physician < ApplicationRecord
  has_many :appointments, counter_cache: :patients_size
  has_many :patients, through: :appointments, counter_cache: :patients_size
end
class Appointment < ApplicationRecord
  belongs_to :physician, counter_cache: :patients_size
  belongs_to :patient, counter_cache: :physicians_size
end
class Patient < ApplicationRecord
  has_many :appointments, counter_cache: :physicians_size
  has_many :physicians, through: :appointments, counter_cache: :physicians_size
end
Now, we’ll hop back into the Rails console and test it out:
1] pry(main)> p = Physician.last
  Physician Load (4.5ms)  ...
=> #<Physician:0x00007fd7aa4acb60. ..>
[2] pry(main)> p.patients.size
  => 1
Yay! Now it works!
Note that I added the
:counter_cacheoption on bothhas_mayassociations. If not, and you tried to dop.appointments.size, it would query the DB again.
Conclusion
This was a tough nut to crack. Again, most blog posts on the web don’t outline this gotcha. The answer is in the Rails docs but somewhat hidden. I hope this post helps another dev save some time and sanity.
