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_to pair without inverse_of on both sides
  • Polymorphic associations cannot auto-detect inverse
  • through associations lose inverse tracking
  • Custom foreign_key prevents automatic inverse detection
  • has_one without matching inverse_of on the inverse side

Step-by-Step Fix

  1. 1.Add inverse_of to bidirectional associations:
  2. 2.```ruby
  3. 3.class User < ApplicationRecord
  4. 4.has_many :posts, inverse_of: :author
  5. 5.has_one :profile, inverse_of: :user
  6. 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. 1.Verify object identity is maintained:
  2. 2.```ruby
  3. 3.user = User.includes(:posts).first
  4. 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. 1.Fix polymorphic association workaround:
  2. 2.```ruby
  3. 3.# Polymorphic associations cannot use inverse_of automatically
  4. 4.class Comment < ApplicationRecord
  5. 5.belongs_to :commentable, polymorphic: true
  6. 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. 1.Fix through association inverse chain:
  2. 2.```ruby
  3. 3.class Author < ApplicationRecord
  4. 4.has_many :authorships, inverse_of: :author
  5. 5.has_many :books, through: :authorships, inverse_of: :authors
  6. 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. 1.Detect missing inverse_of automatically:
  2. 2.```ruby
  3. 3.# Add to config/initializers/active_record_inverse_check.rb
  4. 4.ActiveSupport.on_load(:active_record) do
  5. 5.ActiveRecord::Reflection::AssociationReflection.prepend(
  6. 6.Module.new do
  7. 7.def macro
  8. 8.if [:has_many, :has_one, :belongs_to].include?(super) &&
  9. 9.!polymorphic? &&
  10. 10.inverse_of.nil? &&
  11. 11.!options.key?(:inverse_of)
  12. 12.Rails.logger.warn(
  13. 13."Missing inverse_of on #{active_record.name}##{name}"
  14. 14.)
  15. 15.end
  16. 16.super
  17. 17.end
  18. 18.end
  19. 19.)
  20. 20.end
  21. 21.`

Prevention

  • Always declare inverse_of on both sides of bidirectional associations
  • Use Bullet gem in development to detect missing eager loading
  • Add a RuboCop cop to enforce inverse_of on associations
  • Review association declarations during code review
  • Use includes with named scopes that traverse bidirectional paths
  • Test query counts in integration specs to catch regressions