Diagnosing and Fixing N+1 Queries in Rails: A Field Guide
A diagnostic guide to Rails N+1 queries: recognize the log pattern, trace it to the view, choose between includes/preload/eager_load, and verify the query count stays constant.
28 Nov 2025, 19:56 UTC

Your index page renders fine with ten records in development, then crawls in production with a few thousand. The log shows the same SELECT statement repeated dozens or hundreds of times, once per row. That is the classic ActiveRecord N+1 pattern, and it is fixable in minutes once you know where to look.
The Recognizable Condition
An N+1 query happens when code loads N parent records with one query, then fires one additional query per parent to fetch an association. The symptoms are consistent:
- Response time grows roughly linearly with the number of records rendered.
- The development log shows near-identical statements like
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1repeated with different IDs. - The page works correctly — it is purely a performance problem, which is why it slips through code review.
Cause and Diagnostic Table
| Finding in the log | Likely cause | Where to look |
|---|---|---|
| Repeated SELECT on one associated table | Association accessed in a view loop without eager loading | The rendered template's each block |
| Repeated SELECTs on several tables | Multiple associations used per record (author, comments, tags) | Partial rendered per record |
| One extra query per record inside a serializer | JSON serializer touching associations | Serializer/decorator, not the view |
| Extra COUNT queries per row | post.comments.size on an unloaded association | Counter columns or counter_cache |
Ordered Checks
- Confirm the pattern. In development, ensure SQL logging is on (
config.active_record.verbose_query_logs = trueinconfig/environments/development.rbalso annotates each query with the line of code that triggered it — available since Rails 5.2). Load the page and count statements against the associated table. - Locate the action. The log line
Started GET "/posts"names the controller and action. Open that action and find the query that fetches the parent records. - Find the association access. Read the template and any partials for calls like
post.comments.each,post.author.name, orpost.tags.any?. Each one is a candidate N+1 source. - Check the parent query. If the controller does
@posts = Post.allorPost.where(...)with noincludes,preload, oreager_load, you have found the gap.
Fixes Tied to Findings
The standard fix is eager loading in the controller query. Run this in the controller or a query object — no special permissions needed, but restart is not required for code changes in development:
# app/controllers/posts_controller.rb
def index
@posts = Post.includes(:author, :comments).order(created_at: :desc).limit(50)
endWhich method to use depends on what you found:
includes— the default choice. Rails picks between a separate query per association (preload) or a JOIN (eager_load) depending on whether you reference the associated table inwhereororder.preload— forces separate queries. Use it when a JOIN would produce huge row duplication, e.g. eager loading twohas_manyassociations at once, which multiplies rows combinatorially.eager_load— forces a LEFT OUTER JOIN. Use it when you filter or sort on the associated table. Watch for duplicate parent rows; add.distinctif the join fans out.
If the N+1 comes from post.comments.size, eager loading fixes it but loads every comment row into memory. A counter_cache column (comments_count on posts, with belongs_to :post, counter_cache: true on Comment) removes the query entirely. That requires a migration and a backfill — a state-changing operation, so test on a copy of production data and be prepared to revert the migration if counts drift.
For associations reached only in some branches (e.g. conditional rendering), includes in the controller can over-fetch. In those cases, load lazily at the point of use with ActiveRecord::Associations::Preloader.new.preload(records, :comments), available in modern Rails versions.
Verifying the Fix
Do not trust intuition — re-run the check that found the problem:
- Reload the page and count SELECT statements against the associated table. It should now be constant (usually one) regardless of how many parents render.
- Scale the data: create 10 versus 100 posts in a console or seed script and confirm the query count does not change.
- Optionally add a regression test that counts queries with
ActiveSupport::Notificationssubscribed tosql.active_record, asserting the action issues a bounded number of statements. This catches future regressions when someone edits the view.
Limitations and Escalation
Eager loading is not free. Including associations the view never uses wastes memory and can slow the query; audit with tools like rack-mini-profiler or the bullet gem, which flags both unused eager loading and N+1s in development. If query count is constant but the page is still slow, the bottleneck has moved — check for missing indexes on foreign keys, expensive partial rendering, or serialization cost. Escalate to a DBA or framework-level profiling when a single query (not the count) dominates: that is an indexing or query-shape problem, not an N+1.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.