Back to Course

Senior Track · Lesson 1

Eager Loading, includes, preload, eager_load, and joins

Four query methods. Three different SQL strategies. One decision tree you'll use weekly. By the end of this lesson you'll know which to reach for without thinking.

The N+1 Problem in 30 Seconds

Imagine a page that lists 50 posts and shows each post's author name. The naive code looks innocent:

posts = Post.limit(50)
posts.each do |post|
  puts post.author.name
end

That single block runs 51 SQL queries: one to fetch the posts, then one more per post to fetch its author. The N posts give you N+1 queries, hence the name.

Solving N+1 is the line between an intermediate Rails developer and a senior one. The intermediate developer knows includes exists. The senior developer knows which of the four loading methods is the right one in each situation, and can read the generated SQL to prove it.

The Four Methods

Active Record gives you four ways to pull associations into a query. They look similar from the Ruby side, but they generate very different SQL.

preload: Two queries, no JOIN

preload always issues two separate queries: one for the parent records, one for the associated records. It then stitches them together in Ruby using the foreign keys.

Post.preload(:author).limit(50)
# SELECT * FROM posts LIMIT 50
# SELECT * FROM users WHERE id IN (1, 2, 3, ...)

Use it when: you only need to read the association, display the author's name, count tags, render comments. You're not filtering or sorting by anything in the associated table.

eager_load: One query with LEFT OUTER JOIN

eager_load issues a single query with a LEFT OUTER JOIN and prefixed column aliases so Active Record can hydrate both the parent and the association from one result set.

Post.eager_load(:author).where("users.role = ?", "admin")
# SELECT posts.id AS t0_r0, posts.title AS t0_r1, ...,
#        users.id AS t1_r0, users.name AS t1_r1, ...
# FROM posts
# LEFT OUTER JOIN users ON users.id = posts.author_id
# WHERE users.role = 'admin'

Use it when: you need to filter or sort by columns in the associated table and use the association in Ruby afterward. LEFT OUTER JOIN means parents without children still appear in the result.

includes: The smart one

includes is a chameleon. It starts as a preload, but if Rails detects you're filtering or sorting by the associated table, it silently upgrades to eager_load.

# Behaves like preload (two queries):
Post.includes(:author).limit(50)

# Behaves like eager_load (one JOIN query),
# because we reference the users table in WHERE:
Post.includes(:author).where("users.role = ?", "admin")

That auto-upgrade is convenient, and a common source of senior-level confusion. If Rails references the joined table from a string condition, it works. If you reference it through a complex SQL fragment that Rails can't introspect, the upgrade fails silently and the query errors with "missing FROM-clause entry for table". The fix is to use references(:author) explicitly, or call eager_load directly.

Use it when: you want Rails to make the choice and the conditions are simple enough that it can. Otherwise, be explicit.

joins: The filter, not a loader

joins is the one that catches intermediate developers. It uses a INNER JOIN, but it does not select the associated columns. The association is used to filter the parent records, nothing more.

Post.joins(:author).where(users: { role: "admin" })
# SELECT posts.* FROM posts
# INNER JOIN users ON users.id = posts.author_id
# WHERE users.role = 'admin'

# Now: post.author.name still triggers a separate query,
# because we joined but didn't load.

Use it when: you want to filter by the association but don't need its data. Example: "give me posts where the author is an admin", but you only render post.title, never post.author.something.

Common mistake: using joins and then accessing the association in the view. You filtered out half the rows but still hit N+1 on the half that remains.

The Decision Tree

Here is the rule a senior keeps in their head. Walk it top to bottom.

Do you need the association data in Ruby
(rendering it, iterating it, calling methods on it)?

├── No  →  Are you filtering or sorting by the
│         association's columns?
│         ├── Yes →  joins
│         └── No  →  You don't need any of these.
│
└── Yes →  Are you also filtering or sorting by the
          association's columns?
          ├── No  →  preload
          └── Yes →  eager_load
                    (or includes + references)

Analogy: Think of these methods like ways of inviting friends to a dinner party. preload is calling each friend separately and asking what they want to eat. eager_load is sending one group text to everyone at once and getting all the answers in one round. joins is asking only "who's bringing wine?", you don't actually invite anyone, you filter for wine-bringers. includes is your assistant who picks the right method for you, most of the time.

When preload Beats eager_load

Most intermediate developers default to includes. Most senior developers reach for preload when they don't need filtering. Why?

  • Predictability. preload always issues two queries. You know exactly what hits the database.
  • No accidental Cartesian explosion. A LEFT OUTER JOIN with multiple has_many associations multiplies rows. Postgres returns more data; Active Record dedupes it; everyone wonders why a 50-row query is slow.
  • Better use of indexes. Two simple queries with id IN (...) often outperform one big JOIN, especially when the parent and child tables are large.

Run EXPLAIN ANALYZE on both versions when the page is slow. The answer is in the execution plan, not in the Ruby.

Reading the Generated SQL

The single most useful skill in this lesson is reading the SQL Active Record produces. In a Rails console:

puts Post.includes(:author).where(published: true).to_sql

# In tests:
ActiveRecord::Base.logger = Logger.new($stdout)

# In development, watch the rails server log
# while you click around the page.

If you can't tell at a glance whether your query is firing one round trip or fifty, you're guessing. Always confirm with the log.

Going Deeper: Nested and Multiple Associations

All four methods accept the same syntax for nested and multiple associations:

# Nested: load each post's author and each post's comments,
# and for each comment, its replies.
Post.preload(:author, comments: :replies)

# Mixed: filter by tag (joins) but render comment counts (preload).
Post.joins(:tags).where(tags: { slug: "rails" }).preload(:comments)

Mixing joins with preload on the same query is one of the most useful patterns in Rails: you filter on one association without paying the JOIN cost on the others.

A Tool for Catching N+1 in Development

The Bullet gem watches your queries in development and tells you when an N+1 fires, when an eager-load was unnecessary, and when an unused association was preloaded. Add it to the development group of your Gemfile and trust it more than your own eyes.

# Gemfile
group :development do
  gem "bullet"
end

# config/environments/development.rb
config.after_initialize do
  Bullet.enable        = true
  Bullet.bullet_logger = true
  Bullet.alert         = true
end

Practice Exercise

Open the Rails console of any project you have. Pick a model with at least one belongs_to and one has_many association.

  1. Run Model.preload(:assoc).to_sql and read the SQL. Notice it's only the parent query, the second query is hidden until you actually iterate.
  2. Run Model.eager_load(:assoc).to_sql and notice the LEFT OUTER JOIN with the prefixed column aliases.
  3. Run Model.joins(:assoc).to_sql and notice the INNER JOIN, and that SELECT is still only the parent's columns.
  4. Run Model.includes(:assoc).where(assoc_table: { col: x }).to_sql and notice it switches to a JOIN on its own.

Teaching Tip: Once the SQL each method produces is in your hands, the decision tree is no longer a memorized rule, it's a tool you reach for. The four methods stop being interchangeable Ruby calls and start being four different shapes of database round trip, each with a place where it wins.

Summary

  • preload, two queries, no JOIN. Default for "I need the data, nothing fancy."
  • eager_load, one query, LEFT OUTER JOIN. Use when you also filter or sort by the association.
  • includes, Rails picks one of the above. Convenient; occasionally surprising.
  • joins, INNER JOIN as a filter. Doesn't load anything. Don't use the association after.

Read the SQL. Confirm with the log. Reach for preload by default, and only upgrade when you have a reason.

Related lessons

← Back to Course Senior Track · Lesson 1