Skip to Content
Course content

136: Eager Loading Associations

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a pull request for a junior developer who had built a "Project Overview" dashboard. In the staging environment, with maybe twenty records, the page felt snappy. But the moment it hit production with thousands of projects, the site practically ground to a halt. When I looked at the production logs, I didn't see one or two queries; I saw a literal wall of text—thousands of nearly identical SELECT * FROM tasks WHERE project_id = ? queries scrolling past in a blur. He had accidentally built an N+1 query, and it was killing the database.

This is the classic performance trap in ActiveRecord. An N+1 query happens when you fetch a collection of records (the "1" query) and then, while iterating through them, you trigger another query for each individual record to fetch its associations (the "N" queries). If you have 100 projects, you end up making 101 database calls. That's an incredibly expensive way to get data.

Stopping the Bleeding with .includes

The most common way to fix this is by using the .includes method. Instead of waiting until the loop to fetch the associations, you tell ActiveRecord upfront that you're going to need that data. It’s essentially saying, "Hey, I'm getting these projects, and I know I'm going to need their tasks, so just grab them all now."

# The slow way (The N+1 trap)
projects = Project.limit(10)
projects.each do |project|
  puts project.tasks.map(&:name) # Hits the DB for every single project
end

# The fast way (Eager Loading)
projects = Project.includes(:tasks).limit(10)
projects.each do |project|
  puts project.tasks.map(&:name) # Data is already in memory; no extra queries
end

When you use .includes, ActiveRecord is smart. By default, it usually performs two queries: one for the main records and one for all associated records linked to those IDs. This is vastly more efficient than hitting the database 101 times.

Choosing Between Preload and Eager Load

Under the hood, .includes decides whether to use .preload or .eager_load. While you can usually stick with .includes, there are times when you need to be explicit about how the data is fetched, especially when you start filtering your results based on the associated table.

.preload uses a separate query to fetch the associations. It's clean and fast, but it has a major limitation: you cannot reference the preloaded table in a where clause because that table isn't part of the initial SQL query.

.eager_load, on the other hand, uses a massive LEFT OUTER JOIN to pull everything into one single, giant result set. This is your go-to when you need to filter the primary records based on a value in the associated table.

# This will crash with .preload because 'tasks' isn't in the main query
Project.preload(:tasks).where("tasks.priority = 'High'") 

# This works because it forces a JOIN
Project.eager_load(:tasks).where("tasks.priority = 'High'")

I generally recommend starting with .includes. If you find yourself needing to filter by the association, switch to .eager_load. Just keep an eye on your memory usage; joining massive tables can sometimes create a result set that's too large for Ruby to handle comfortably.




📋 Practical Task

Optimizing the Invoice Line Item Report

You have been handed a piece of legacy code that generates a summary report for invoices. Currently, the code is causing a massive performance bottleneck because it triggers an N+1 query for every invoice's line items and the client associated with those line items.

The current slow implementation:

# This code is killing the server!
invoices = Invoice.where(status: 'pending').limit(50)

invoices.each do |invoice|
  puts "Invoice ##{invoice.id}"
  invoice.line_items.each do |item|
    puts "- #{item.description}: $#{item.price} (Product Category: #{item.product.category})"
  end
end

Your Task: Rewrite the first line (the query) to eager load both the line_items and the product associated with each line item. You will need to use a nested hash syntax to reach the product through the line items. Ensure that the entire loop can execute without triggering any additional database queries after the initial fetch.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.