← All posts

May 21, 2026

includes vs joins vs preload: a decision guide

The four Active Record association loaders, what SQL each generates, and the rule I apply at the keyboard to pick the right one.

rails active-record n+1 performance

There are four ways to load associations in Active Record: includes, preload, eager_load, and joins. They look similar. They produce dramatically different SQL. Picking the wrong one is the most common cause of “I added eager loading and the query got slower.”

This is a decision guide. Examples are illustrative.

What each one generates

Take a domain with Post belongs_to :author and Post has_many :comments.

Post.preload(:author).limit(10) — two queries. First: SELECT * FROM posts LIMIT 10. Second: SELECT * FROM authors WHERE id IN (...). Active Record stitches the results together in Ruby. No JOIN; no opportunity to filter posts by author attributes; two clean SELECTs.

Post.eager_load(:author).limit(10) — one query with a LEFT OUTER JOIN: SELECT posts.*, authors.* FROM posts LEFT OUTER JOIN authors ON authors.id = posts.author_id LIMIT 10. Author columns come back aliased (authors_id, authors_name, etc.) and Active Record builds the objects from a single result set. You can filter by author attributes in the WHERE.

Post.joins(:author).limit(10) — one query with an INNER JOIN, but no author columns selected: SELECT posts.* FROM posts INNER JOIN authors ON authors.id = posts.author_id LIMIT 10. The author records aren’t loaded — calling post.author on a result will fire a fresh query (the N+1 you were trying to avoid). joins is for filtering, not for loading.

Post.includes(:author).limit(10) — Active Record decides at runtime whether to use preload or eager_load, based on whether the rest of the query references the association. If you don’t touch authors in the rest of the query, you get the two-query preload behavior. If you reference authors in a where (or in order, in some cases), Active Record promotes to eager_load and emits the JOIN.

Four methods, four behaviors, with includes straddling two of them.

The decision rule

The rule I apply at the keyboard, in this order:

  1. Am I filtering posts based on a column in the joined table? Use joins (if I don’t need the author records loaded) or eager_load (if I do).
  2. Am I loading associations to avoid N+1 in a render? Use preload.
  3. Am I doing both — filter on author and render author info? Use eager_load.
  4. Do I want Active Record to figure it out? Use includes — but understand that the auto-promotion is a runtime decision and the SQL might surprise you.

Below, the four cases in detail.

Case 1: render-only, no filter — use preload

The view shows a list of posts and each post’s author name.

# illustrative
@posts = Post.preload(:author).limit(10)
<%# illustrative %>
<% @posts.each do |post| %>
  <h3><%= post.title %></h3>
  <p>by <%= post.author.name %></p>
<% end %>

Two queries: ten posts in the first, then a single SELECT * FROM authors WHERE id IN (...) for whichever distinct author IDs appeared. No N+1, no JOIN, no surprise SQL. This is the cheapest case and the one I default to when I’m only loading associations for display.

Why not includes here? Because includes will do the same thing in this case (it’ll choose preload because nothing in the query references authors), but the implementation is “Active Record looks at your query and decides.” preload is “I am explicitly telling Active Record to do two queries.” When the SQL surprises me later, the explicit version is easier to debug.

Case 2: filter on the join, don’t render — use joins

I want posts whose author is verified. I don’t need to display the author.

# illustrative
Post.joins(:author).where(authors: { verified: true }).limit(10)

One query, an INNER JOIN, no author columns selected. Filters down to posts with verified authors and returns only posts.*. If the view called post.author on a result, that would fire one query per post — the N+1 — but in this case the view doesn’t, so the absence of loaded authors is exactly what I want.

The classic mistake here is using includes(:author).where(authors: { verified: true }) when you don’t need the author records. Active Record does the right thing (promotes to eager_load, runs the JOIN), but you also get all the author columns hydrated into Active Record objects you’ll never read. On a list of 100 posts, that’s 100 Author objects instantiated for nothing.

Case 3: filter and render — use eager_load

I want posts whose author is verified, and I want to display the author’s name.

# illustrative
@posts = Post.eager_load(:author).where(authors: { verified: true }).limit(10)

One query, a LEFT OUTER JOIN (Active Record uses LEFT for eager_load regardless of whether the association is required), filtered by authors.verified = true, with author columns selected and hydrated. The view’s post.author.name doesn’t fire any further queries.

This is also what Post.includes(:author).where(authors: { verified: true }) produces — Active Record sees the authors reference in the WHERE and auto-promotes. I prefer the explicit eager_load here for the same reason as preload above: when I read the code in three months, the SQL I get is obvious from the method name, not inferred from the rest of the query.

Case 4: deep nesting and the case for includes

Where I do reach for includes: when the association graph is deep enough that I want Active Record to make the call.

# illustrative
Post.includes(:author, comments: [:user, :likes]).limit(10)

That’s posts, with their author, with their comments, with each comment’s user, with each comment’s likes. Five tables. Whether the optimizer wants to JOIN any of them depends on what the rest of the query is doing. includes lets Active Record pick — and for purely “load this nested graph for rendering” code, picking is fine.

The moment I add a .where on any of those tables, I’d switch to explicit eager_load for clarity.

The references escape hatch

There’s a fifth thing worth knowing: if you use includes with a SQL string (not a hash), Active Record can’t tell whether the string references the joined table, and it’ll fall back to preload — which then fails because the SQL string references columns the SELECT doesn’t include.

# illustrative — broken
Post.includes(:author).where("authors.verified = true")
# => ActiveRecord::StatementInvalid: column "authors.verified" does not exist

The fix is .references(:authors), which forces the JOIN:

# illustrative — works
Post.includes(:author).where("authors.verified = true").references(:authors)

I avoid this entirely by using the hash syntax (where(authors: { verified: true })), which lets Active Record see the reference and promote correctly. If you’re stuck with a SQL fragment for some reason — a complex condition, a function call, anything the hash form can’t express — references is the escape hatch.

The N+1 case as told by bullet

The bullet gem reports three classes of problem, and the right fix differs for each.

N+1 query detected. You called an association on each record in a loop and didn’t preload it. Add preload (or includes) for the association.

Unused eager loading detected. You preloaded an association and never called it. Drop the preload.

Counter cache. Bullet noticed you’re calling .count on an association repeatedly and a counter cache would replace the SQL with a memoized integer. Add the counter cache column.

The first one is what most people hear about. The second is the one that bites teams who reflexively .includes(:everything) on every controller index — they fix the N+1, then load three tables of data into memory that never gets read. Bullet flags both, and the right answer is the more specific loader, not the more inclusive one.

A working example

A controller index that lists posts, filtered to those with verified authors, displaying each post’s title, author name, and comment count:

# illustrative
class PostsController < ApplicationController
  def index
    @posts = Post
      .eager_load(:author)
      .preload(:comments)
      .where(authors: { verified: true })
      .order(created_at: :desc)
      .limit(20)
  end
end

Three loaders, three jobs. eager_load(:author) brings in authors with a JOIN because I’m filtering on authors.verified. preload(:comments) does a separate SELECT for comments because I’m not filtering on them, only rendering them. The combination produces two queries (one with a JOIN, one for comments), no N+1, no over-fetching.

I could have written this as includes(:author, :comments).where(authors: { verified: true }) and gotten essentially the same SQL. The explicit form makes the intent obvious to the next reader: yes, JOIN the authors; no, don’t JOIN the comments.

The takeaway

preload for “I want this loaded but won’t filter on it.” joins for “I want to filter on this but won’t render it.” eager_load for “I want both.” includes when you want Active Record to decide.

The wrong loader doesn’t crash — it emits SQL that’s slower than what you wanted, often by an order of magnitude on a large table. Picking the explicit method over includes costs you one extra second of typing and saves you the next debugging session.