Introduction
When inverse_of is not declared on bidirectional ActiveRecord associations, Rails cannot maintain in-memory object identity across the relationship. This causes unexpected N+1 queries when navigating associations, stale object state, and callback duplication. The issue is subtle because queries still work correctly, just inefficiently.
Symptoms
- Bullet gem reports N+1 queries despite using
includes - Same database record loaded as two different Ruby object instances
- Callbacks fire twice (once from each direction of association)
- In-memory changes not reflected when accessing inverse association
- Query count doubles when traversing bidirectional relationships
Check with Bullet gem: ```ruby # Gemfile gem 'bullet', group: 'development'
# config/environments/development.rb config.after_initialize do Bullet.enable = true Bullet.bullet_logger = true Bullet.rails_logger = true end ```
Bullet log output:
``
USE eager loading detected:
User => [:profile]
Add to your finder: :includes => [:profile]
Common Causes
has_many/belongs_topair withoutinverse_ofon both sides- Polymorphic associations cannot auto-detect inverse
throughassociations lose inverse tracking- Custom
foreign_keyprevents automatic inverse detection has_onewithout matchinginverse_ofon the inverse side
Step-by-Step Fix
- 1.Add inverse_of to bidirectional associations:
- 2.```ruby
- 3.class User < ApplicationRecord
- 4.has_many :posts, inverse_of: :author
- 5.has_one :profile, inverse_of: :user
- 6.end
class Post < ApplicationRecord belongs_to :author, class_name: 'User', inverse_of: :posts end
class Profile < ApplicationRecord belongs_to :user, inverse_of: :profile end ```
- 1.Verify object identity is maintained:
- 2.```ruby
- 3.user = User.includes(:posts).first
- 4.post = user.posts.first
# Without inverse_of: different object instances puts user.object_id # => 701234567890 puts post.author.object_id # => 701234567891 (different!)
# With inverse_of: same object instance puts user.object_id # => 701234567890 puts post.author.object_id # => 701234567890 (same!) ```
- 1.Fix polymorphic association workaround:
- 2.```ruby
- 3.# Polymorphic associations cannot use inverse_of automatically
- 4.class Comment < ApplicationRecord
- 5.belongs_to :commentable, polymorphic: true
- 6.end
# Add manual inverse checks on concrete models class Post < ApplicationRecord has_many :comments, as: :commentable, inverse_of: :commentable end
class Photo < ApplicationRecord has_many :comments, as: :commentable, inverse_of: :commentable end ```
- 1.Fix through association inverse chain:
- 2.```ruby
- 3.class Author < ApplicationRecord
- 4.has_many :authorships, inverse_of: :author
- 5.has_many :books, through: :authorships, inverse_of: :authors
- 6.end
class Authorship < ApplicationRecord belongs_to :author, inverse_of: :authorships belongs_to :book, inverse_of: :authorships end
class Book < ApplicationRecord has_many :authorships, inverse_of: :book has_many :authors, through: :authorships, inverse_of: :books end ```
- 1.Detect missing inverse_of automatically:
- 2.```ruby
- 3.# Add to config/initializers/active_record_inverse_check.rb
- 4.ActiveSupport.on_load(:active_record) do
- 5.ActiveRecord::Reflection::AssociationReflection.prepend(
- 6.Module.new do
- 7.def macro
- 8.if [:has_many, :has_one, :belongs_to].include?(super) &&
- 9.!polymorphic? &&
- 10.inverse_of.nil? &&
- 11.!options.key?(:inverse_of)
- 12.Rails.logger.warn(
- 13."Missing inverse_of on #{active_record.name}##{name}"
- 14.)
- 15.end
- 16.super
- 17.end
- 18.end
- 19.)
- 20.end
- 21.
`
Prevention
- Always declare
inverse_ofon both sides of bidirectional associations - Use Bullet gem in development to detect missing eager loading
- Add a RuboCop cop to enforce
inverse_ofon associations - Review association declarations during code review
- Use
includeswith named scopes that traverse bidirectional paths - Test query counts in integration specs to catch regressions