Hunting and killing N+1 queries
The N+1 query is the most common performance bug in Rails apps, and it hides perfectly in development. A deep-dive on what causes it, the difference between preload/eager_load/includes, counter caches, and how to catch it automatically.
If you profile a slow Rails page, the odds are overwhelming that the culprit is an N+1 query. It is the single most common performance bug in Rails, and the cruellest, because it is invisible in development — with twenty records on your laptop the page feels instant, and only in production, with twenty thousand, does it crawl. Here is what causes it, how to fix it properly, and how to make sure it never sneaks back in.
What an N+1 actually is
The bug is a loop that triggers a database query on each iteration. Consider rendering a list of posts with each author’s name:
@posts = Post.all # 1 query: SELECT * FROM posts
@posts.each do |post|
puts post.author.name # 1 query EACH: SELECT * FROM authors WHERE id = ?
end
That is 1 query for the posts, plus N more — one per post — to fetch each
author. Hence “N+1”. With 20 posts it is 21 queries, fast enough to ignore. With 2,000
posts it is 2,001 queries, each with its own round-trip to the database, and your page
takes seconds. The lazy-loading that makes ActiveRecord so convenient — post.author
just works — is exactly what hides the cost: every one of those innocent-looking calls
is a separate query you cannot see in the code.
The fix: eager loading
The solution is to tell ActiveRecord up front which associations you will need, so it
loads them in bulk instead of one at a time. The headline tool is includes:
@posts = Post.includes(:author) # loads all posts AND all their authors efficiently
@posts.each do |post|
puts post.author.name # no query here — already loaded
end
Now there is no per-iteration query at all. includes(:author) fetches the authors in
one additional query (or a join), and post.author reads from memory. Twenty-one
queries become two, and the page is fast at any scale. For nested associations you pass
a hash:
Post.includes(author: :profile, comments: :user)
preload vs eager_load vs includes
This is where it pays to understand what ActiveRecord is actually doing, because there are three related methods and they behave differently:
preloadalways uses two separate queries: one for the posts, oneWHERE author_id IN (...)for all the authors. Clean and predictable, but you cannot reference the associated table in aWHEREorORDER BY, because it was never joined.eager_loadalways uses a single LEFT OUTER JOIN, pulling everything in one query. This is what you need when you want to filter or sort by the associated table —Post.eager_load(:author).where(authors: { active: true }).includesis the smart default: it normally behaves likepreload(two queries), but if it detects that you reference the association in awhereororder, it automatically switches toeager_load(the join).
Most of the time includes does the right thing and you should reach for it. Drop to
preload or eager_load explicitly when you need to control the strategy — for
example, when a join would multiply rows expensively and you would rather have two
queries, or when includes guesses wrong about whether to join.
Not every N+1 needs eager loading
A counter is a special case worth calling out. If all you need is a count of an association — “23 comments” — eager-loading every comment to count them in Ruby is wasteful. A counter cache stores the count on the parent row and keeps it updated automatically:
class Comment < ActiveRecord::Base
belongs_to :post, counter_cache: true # maintains post.comments_count
end
Now post.comments_count is a column read, no query and no loaded associations.
Counter caches are the right fix whenever you are loading records only to count them.
It is also worth saying: not every N+1 is worth fixing. If a page loads exactly one record and touches one association, the “N” is 1 and there is nothing to optimise. Premature eager-loading of associations you do not use just wastes memory loading data you throw away. Fix the N+1s that are actually on a hot path with a real N — measure first.
Catching it automatically
The reason N+1s are so persistent is that they pass every manual test on small data.
The fix is to detect them mechanically. The bullet gem watches your queries in
development and pops up a warning the moment it sees an N+1 (and, helpfully, when you
eager-load something you never use):
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # browser alert
Bullet.bullet_logger = true # and a log file
end
Better still, make it fail your tests. Run Bullet in the test environment and have it raise on an N+1, so a new one breaks CI instead of shipping silently:
Bullet.raise = true # in test config — an N+1 now fails the suite
And keep an eye on the development log: a wall of near-identical SELECT ... WHERE id = ? lines scrolling past on a single page load is the unmistakable fingerprint of an
N+1. Reading your own query log is the cheapest detection tool there is.
Verdict
The N+1 query is the performance bug that will be in your app — not because anyone is
careless, but because ActiveRecord’s lazy loading is convenient enough to hide it
perfectly until production scale exposes it. The cure is not hard: eager-load the
associations you iterate over with includes, understand preload vs eager_load
for the cases where the strategy matters, use a counter cache when you only need a
count, and skip the over-eager loading of data you do not use. The real win, though,
is mechanical detection — run Bullet, fail your tests on N+1s, and watch your query log
— so that the bug that hides in development gets caught in development, where it is
cheap, instead of in production, where it is a page nobody can load.