The N+1 Query Problem
Fast recall
Quick revision
Core answer
post.author.name in a loop can produce one posts query plus N author queries. includes, preload, or an appropriate join can fetch the required relationship in a bounded number of queries; the right choice depends on whether the related table is only loaded or also filters the result.Whether you can spot N+1 (one query + N per-record queries) and fix it with includes (eager loading).
Not noticing the N+1 in the log, or 'fixing' it by caching instead of eager loading.
N+1 = 1 query for the records + N per-record association queries. Fix with Post.includes(:comments) to eager-load in one extra query.
Interview-ready guide
Interview answer
An N+1 problem starts with one query for a collection and adds one association query for each of its N records. If twenty posts are loaded and a loop lazily reads every post.author, the page can run one posts query plus twenty author queries. Database round trips grow with the collection size.
The controller query may look harmless because the extra work begins later in a view or serializer. The first read of each unloaded association asks the database for that record's author. Query caching can hide a few duplicates, but it does not turn this growing access pattern into a reliable bounded plan.
Use Post.includes(:author) when the response will read authors and Rails may choose separate queries or a join as needed. preload(:author) explicitly uses separate association queries, while eager_load(:author) uses a left outer join. Each changes the loading plan before the loop starts.
Use joins(:author) when author columns decide which posts SQL should return. A join alone does not promise that later post.author reads are preloaded, so a filtered result may use joins(:author).preload(:author). Joined collections can also duplicate parent rows and complicate pagination, especially with has-many relations.
Do not eager-load every association by habit; unused rows and wide joins cost memory and transfer time. Check the actual view or serializer, then confirm the fix in query logs, a query-count test, strict loading, or profiling. The goal is a small bounded query plan that loads only what the response uses.
The N+1 Query Problem
Direct Answer
An N+1 query occurs when code runs one query for a parent collection and then one additional association query for each of its N records. For example, loading posts and calling post.author.name in a loop can produce one posts query plus N author queries. includes, preload, or an appropriate join can fetch the required relationship in a bounded number of queries; the right choice depends on whether the related table is only loaded or also filters the result.
Deep Dive & Architecture
A relation can load the parent rows without loading any association. Each association proxy remembers whether its target is loaded. The first read of an unloaded post.author asks the database for that one post's key; repeating the read for different posts repeats the round trip.
Preloading collects keys from all parents and fetches matching children together, then assigns each child to its owner in memory. This changes the number of trips from a value proportional to N to a small bounded number, often two for one association.
Join-based loading solves a different shape. It lets SQL filter or order by related columns, but parent data may be duplicated once per child row and pagination can become subtle. Separate preloading avoids row multiplication, while a join may be necessary for the actual predicate.
Detection belongs near the rendered access path. A controller query can look harmless until a serializer asks for two nested associations. Development logs, Bullet-like tooling, strict loading, and request-level query assertions reveal the real boundary and protect it during later view changes.
Code Example
class RecentPosts
def self.with_author_names(limit: 20)
Post.includes(:author)
.order(created_at: :desc)
.limit(limit)
.map do |post|
{ title: post.title, author: post.author.name }
end
end
end
rows = RecentPosts.with_author_names(limit: 2)
rows.each { |row| puts "#{row[:title]} — #{row[:author]}" }
Common Mistakes & Pitfalls
Not noticing the N+1 in the log, or 'fixing' it by caching instead of eager loading.
Check your understanding
Answer these without looking back at the article.
- What is the difference between `includes`, `preload`, `eager_load`, and `joins`?
- Why can eager-loading every association also hurt performance?
- How would you prevent an N+1 regression in a request or serializer?
Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.