Associations and eager loading

Ruby on Rails · Practical · 15 min

Real domains are relational, and N+1 queries are the most common Rails performance problem.

Associations declare relationships and generate methods:

  • belongs_to :author, class_name: "User" on the model holding the foreign key (author_id). Since Rails 5 it is required by default; use optional: true otherwise.
  • has_many :comments, dependent: :destroy on the other side. dependent: decides what happens to children when the parent is destroyed.
  • has_one :profile.
  • has_many :tags, through: :taggings for many-to-many via a join model, which can carry its own columns. has_and_belongs_to_many exists but a join model is more flexible.
  • Polymorphic: belongs_to :commentable, polymorphic: true.

Generated methods include article.comments (a relation you can chain: article.comments.where(approved: true)), article.comments.create!(...) (sets the foreign key), comment.article, article.tag_ids = [1, 2]. counter_cache: true on belongs_to keeps a comments_count column up to date.

The N+1 problem: listing 50 articles and calling article.author.name in the view runs 51 queries. Fix it by eager loading:

  • includes(:author, comments: :user) lets Rails choose between separate queries and a JOIN.
  • preload always uses separate queries; eager_load always uses a LEFT OUTER JOIN (needed when you filter on the association).
  • joins(:comments) is for filtering only and doesn't load the association.

Turn on strict_loading (per model, per query or app-wide) to raise on lazy loads during development.

Example

class User < ApplicationRecord
  has_many :articles, foreign_key: :author_id, inverse_of: :author
end

class Article < ApplicationRecord
  belongs_to :author, class_name: "User"
  has_many :comments, dependent: :destroy
  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings
end

class Comment < ApplicationRecord
  belongs_to :article, counter_cache: true   # needs articles.comments_count
  belongs_to :user
end

# N+1: 1 query for articles + 1 per article for its author
Article.limit(50).each { |a| puts a.author.name }

# Eager loaded: 3 queries total
Article.includes(:author, :tags).limit(50).each do |a|
  puts "#{a.title} by #{a.author.name} [#{a.tags.map(&:name).join(', ')}] #{a.comments_count}"
end
Article.joins(:tags).where(tags: { name: "rails" }).distinct   # filter only
Article.strict_loading.first.author   # raises ActiveRecord::StrictLoadingViolationError

Remember

  • belongs_to holds the foreign key and is required by default
  • has_many :through models many-to-many with a real join model
  • includes/preload/eager_load fix N+1; joins only filters

Watch out for

  • Calling association methods inside a loop over records loaded without includes.

Check yourself

  1. You need articles WHERE tags.name = 'rails', and you also display each article's tags. Which is right?
  2. What does belongs_to :author do if author_id is nil when saving (Rails 5+)?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: guides.rubyonrails.org/association_basics.html