Skip to content
Joey Wang
Menu

Search

Full Stack Development

N+1 Queries in Rails: What Silently Breaks `includes`

How ActiveRecord's includes prevents N+1 queries in Rails, and the common mistakes with order, filtering, and pluck that silently break it.

· 2 min read

rails #rails#performance#database#debugging

Audio summary

includes fixes the N+1 query problem in Rails, until a second line of code quietly undoes it. The failure mode is not “forgot to use includes”, it’s “used includes correctly, then wrote the next line in a way that invalidates it.”

The problem

Take a Program that has many courses, each with a teacher:

class Program < ApplicationRecord
  has_many :courses
end

class Course < ApplicationRecord
  belongs_to :program
  belongs_to :teacher
end

class Teacher < ApplicationRecord
  has_many :courses
end

Fetch programs and walk their courses to get teachers, and you get N+1:

Program.limit(5).each do |program|
  program.courses.each do |course|
    puts course.teacher.name
  end
end

That’s one query for programs, one query per program for its courses, and one query per course for its teacher. Five programs with ten courses each is 1 + 5 + (5 * 10) = 56 queries.

includes collapses that to three:

Program.includes(courses: :teacher).limit(5).each do |program|
  program.courses.each do |course|
    puts course.teacher.name
  end
end

One query for programs, one for all their courses (WHERE program_id IN (...)), one for all the teachers (WHERE id IN (...)). 56 down to 3.

What breaks it after the fact

Calling .order after preloading

# Bad: fires a new query per program
Program.includes(:courses).each do |program|
  program.courses.order(:name).each do |course|
    puts course.name
  end
end

includes(:courses) loads every course in one query. .order(:name) on the association re-queries per program anyway, because ordering an already-loaded association is treated as a new scope, not a sort of what’s in memory. Order before loading instead:

Program.includes(:courses).order("courses.name").each do |program|
  program.courses.each do |course|
    puts course.name
  end
end

Filtering inside the loop

# Bad: .where on a loaded association still queries
Program.includes(:courses).each do |program|
  active_courses = program.courses.where(active: true)
  active_courses.each { |course| puts course.name }
end

Same problem: .where on an association object is a new query, not a filter over what’s already loaded. Push the condition into the original query instead:

Program.joins(:courses).where(courses: { active: true }).each do |program|
  program.courses.each { |course| puts course.name }
end

.pluck instead of .map

# Bad: pluck bypasses the preloaded association entirely
programs = Program.includes(:courses)
programs.each do |program|
  course_names = program.courses.pluck(:name)
end

.pluck always hits the database; it has no concept of “this is already loaded.” .map works on the in-memory records:

programs = Program.includes(:courses)
programs.each do |program|
  course_names = program.courses.map(&:name)
end

The pattern

All three mistakes share a cause: any method that builds a new ActiveRecord scope on an association, .order, .where, .pluck, discards the preloaded data and queries again. includes only helps for operations performed on the loaded array itself. Check query logs (or the bullet gem) after adding includes, not just before, because the eager load can be silently thrown away three lines later.