ActiveRecord Eager Loading Pitfalls: Subqueries vs. IN Clauses
includes is the most convenient lie in ActiveRecord. You write one method call, and ActiveRecord decides for you whether to emit two queries joined by an IN clause (preload) or one LEFT OUTER JOIN (eager_load). That decision is usually fine — until your dataset is large and the IN clause becomes the slow part.
How ActiveRecord decides
The rule of thumb: includes uses preload semantics by default (two queries) and silently switches to eager_load the moment you reference the association in a where or order. That silent switch is where the trouble starts, because now you are filtering on a joined table and ActiveRecord has committed you to the join.
1
2
3
4
5
6
7
# Two fast queries, IN clause of N ids
Post.where(status: :published).preload(:comments)
# One LEFT OUTER JOIN, because we filter on authors.name
Post.where(status: :published)
.eager_load(:author)
.where(authors: { name: "Sijin" })
Check to_sql on the second one and you get a join over posts, authors, and whatever the schema cache has indexed. The join version is not wrong — it is required when you filter on association columns — but you should choose it explicitly with eager_load instead of discovering the switch happened by reading an EXPLAIN later.
The IN clause ceiling
preload fetches parent ids and issues WHERE comments.post_id IN (…). With 10,000 parents, that is a 10,000-element literal list. Postgres plans it fine but the SQL text is huge and planning time climbs; on MySQL it is worse because the list is recompiled per statement. Our measured numbers on a 12k-parent preload:
1
2
IN with 12,000 ids : ~240 ms, ~4.2 MB of SQL text
Same query as subquery : ~45 ms, constant-size SQL
The subquery form replaces the literal list with a derived table:
1
2
post_ids = Post.where(status: :published).select(:id)
Comment.where(post_id: post_ids) # WHERE comments.post_id IN (SELECT id FROM posts ...)
ActiveRecord composes this for associations too:
1
2
3
Post.where(status: :published)
.preload(:comments)
.where(id: Comment.where(spam: false).select(:post_id))
That is a subquery filter plus a separate comments load — no join, no giant IN list. When the parent set is itself a filtered subset, PostgreSQL folds the subquery into a semi-join and both queries stay index-driven.
When you still want the join
You need eager_load (or an explicit joins) in exactly two cases:
- Filtering or ordering by an association column, e.g.
order(authors: { name: :asc }). - The association is small enough that one round trip beats two, and you accept the row multiplication when a parent has many children.
The third case — “just get all the data” — is preload territory, always. The classic bug is includes(...).joins(...): your joins already forced a join, so you pay for both representations and Rails silently discards the preload. Pick one.
Check the plan before you ship
.explain is your friend, and the thing to look for is not the query shape — it is the plan:
1
> Post.where(status: :published).preload(:comments).explain
A Seq Scan on comments with Filter: post_id = ANY($1) means your foreign-key index is missing, and no subquery will save you then. Index the join columns and the status filter, and most of this argument becomes moot.
Production rules we follow
- Let
includesstaypreload; makeeager_loadexplicit whenever the join matters. - Never let an IN list exceed ~2,000 ids; use
.select(:id)subqueries instead — constant SQL size, planner-friendly. - For large parent sets, prefer
find_in_batcheswith windowed pagination (id > last_id ORDER BY id LIMIT …) so every batch’s IN list stays small, andpreloadper batch. - If an association is huge, skip eager loading its data entirely; load on demand or cache the projection.
- Profile the database, not the ActiveRecord call count. Two indexed queries at 10ms beat one join at 200ms, and
includesrarely tells you which you are getting.
Eager loading is a query-plan decision wearing a convenience API. Once you know which plan you actually want, the SQL — and the fix — is unambiguous.