You ship a /posts index page. It renders 50 posts, and for each one it shows the author's name with post.author.name. QA says the page is slow, and the logs are full of repetitive SQL.
- What is this problem called, and why is it happening?
- How would you detect it, in dev and in a running app?
- How do you fix it, and what's the difference between the main fixing strategies?
Answer: N+1 queries
What it is and why it happens
This is the N+1 query problem. One query loads the 50 posts, then Active Record fires one more query per post to load post.author. That's 1 + N queries (1 for posts, 50 for authors) when it could be 2, or even 1. Active Record associations are lazy by default, so the author isn't loaded until you call post.author. Do that inside a loop and you get a round trip per record.
How to detect it
- Dev logs: you'll see the same
SELECT ... FROM authors WHERE id = ?over and over with different IDs. That repetition is the tell. - The Bullet gem: built for this. It warns you in dev when you should add eager loading, and also when you're eager-loading something you don't need.
- An APM in production (Sentry, New Relic, Scout): flags endpoints firing a lot of queries.
- Tests: assert on query count with something like
assert_queriesor an RSpec matcher so CI catches a regression before it ships.
How to fix it
Eager-load the association:
# N+1
@posts = Post.all
@posts.each { |p| puts p.author.name } # 1 + 50 queries
# Fixed
@posts = Post.includes(:author)
@posts.each { |p| puts p.author.name } # 2 queries
There are four tools, and they don't do the same thing:
| Method | What it does | When to use it |
|---|---|---|
preload |
Loads the association in a separate query and matches it in memory | You just need the data and aren't filtering or ordering by the association |
eager_load |
One LEFT OUTER JOIN that loads everything in a single query |
You need to filter or order by the association and read its attributes |
includes |
Defaults to preload, but switches to eager_load if you reference the association in a where or order
|
Your usual default. Let Rails decide |
joins |
INNER JOIN for filtering or sorting. Does not load the association into memory |
You need to filter by the association but don't read its attributes |
The trap: joins alone does not preload. If you joins(:author) and then call p.author.name in the loop, you're right back to N+1. Reach for includes or eager_load when you actually read the association's attributes.