Importance of inverse_of for N+1 prevention

Published on .

Most Rails developers know about includes and how it prevents N+1 queries problem. However includes is not always the best option so let’s explore when you’d want to use something else.

The setup

Imagine you have a blog with a list of comments on the blog page and you want to include country code next to the author of comment unless they are from the same country as the author of the blog post.

class Post < ApplicationRecord
  belongs_to :author
  has_many :comments, foreign_key: :blog_post_id
end

class Comment < ApplicationRecord
  belongs_to :author
  belongs_to :blog_post, class_name: "Post"
end

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
  end
end
// app/views/posts/show.html.erb
<% @post.comments.each do |comment| %>
  <%= comment.author.name %>
  <% if comment.author.country_code != comment.blog_post.author.country_code %>
    <%= comment.author.country_code %>
  <% end %>
<% end %>

How many SQL queries do you think this page will generate if the post has 10 comments?

The problem

The answer is this page makes 32 queries. Perhaps you guessed 13:

  • 1 to find post
  • 1 to find comments
  • 10 to find comment authors
  • 1 to find post author

But that’s not correct, we actually have:

  • 1 to find post
  • 1 to find comments
  • 10 to find comment authors
  • 10 to find post of the comment
  • 10 to find author of the post found previously

And while we can solve 10 queries for comments’ authors with includes, solving the other 20 is better done using different approach.

Why did it happen?

The reason it happened is because Rails was not able to automatically determine that :blog_post and :comments are inverse associations of each other. In that case @post and @post.comments.first.blog_post are actually different objects and have different object_id.

Thus each comment has to load the post from the database when we call comment.blog_post and then each post has to load its author when we call blog_post.author. It doesn’t matter that they’re same object, Rails doesn’t know that.

The fix

In order to fix our problem we need to make Rails aware that comments and blog_post associations are inverse of each other. Often Rails automatically determines the inverse association automatically and we don’t see this problem with simple associations, but there are multiple reasons why rails is unable to do that though, e.g. specifying foreign_key option.

In cases like these you have to specify inverse association manually, e.g. using has_many :comments, foreign_key: :blog_post_id, inverse_of: :blog_post. In fact this one small change fixes our N+1 problem for comment.blog_post and comment.blog_post.author.

Keep in mind though that inverse_of often needs to be manually specified on both sides of association, e.g. comment.object_id.in?(comment.blog_post.comments.map(&:object_id)) will still return false, until we fix the other side using belongs_to :blog_post, class_name: "Post", inverse_of: :comments.

The side effects

Specifying inverse_of for associations can have a side effect. It changes how this behaves:

new_comment = Comment.new(blog_post: @post)
@post.comments

Before inverse_of this would return [], but now it returns [new_comment]. Thus in bigger codebases this might result in breaking some expectations.

Conclussions

Not all N+1 problems can simply be solved with includes. One of the other common problems Rails code bases have is lack of inverse associations. So make sure all your associations have inverse association set - you can quickly check that Model.reflect_on_association(:association_name).inverse_of doesn’t return nil to make sure. And why not write a test that makes sure all your models have their associations defined with inverses?