LEARN · DEBUGGING GUIDE

Detecting and Fixing ActiveRecord N+1 Queries in Rails

N+1 queries silently kill performance. This guide shows you exactly how to spot them with Bullet, rack-mini-profiler, and logs, then fix them with eager loading, preload, or includes.

IntermediateDatabase7 min read

What this usually means

An N+1 query occurs when your code loads a collection of records (like `@posts = Post.all`) and then, for each record, lazily loads an associated record (like `post.comments`). Each lazy load triggers a separate SQL query, leading to 1 query for the initial collection plus N queries for each of the N records. This is a classic ActiveRecord anti-pattern caused by not understanding Rails' lazy loading. The fix is to use eager loading (`includes`, `preload`, or `eager_load`) to fetch all associated data in a single query (or two at most).

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Enable Bullet in development: add `gem 'bullet'` to Gemfile, run `bundle install`, then configure `Bullet.enable = true` and `Bullet.rails_logger = true` in config/environments/development.rb. Bullet will print N+1 warnings to the log.
  • 2Add rack-mini-profiler: `gem 'rack-mini-profiler'` and visit any page. Look at the SQL query count in the top-left badge. If it's > 2x the number of records, you likely have N+1.
  • 3Inspect the Rails development log: `tail -f log/development.log` and look for repeated identical SELECT queries with different IDs in the WHERE clause.
  • 4Use `ActiveRecord::Base.logger = Logger.new(STDOUT)` in a console session to see queries as you iterate over associations.
  • 5Run a manual benchmark: in a Rails console, time `User.all.each { |u| u.posts.count }` vs `User.includes(:posts).each { |u| u.posts.size }`.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • search`log/development.log` – search for repeated SELECT statements with the same table and different IDs.
  • search`app/views/` – check any view that iterates over a collection and accesses an association (e.g., `@posts.each { |p| p.comments }`).
  • search`app/controllers/` – look for controller actions that assign instance variables without `includes`.
  • search`app/serializers/` – if using active_model_serializers, ensure associations are included in the serializer's `associations` block.
  • search`app/models/` – check for `has_many` and `belongs_to` declarations that might be used without eager loading in queries.
  • searchBullet output – Bullet logs the exact file and line number where the N+1 is triggered.
  • searchRack-mini-profiler flamegraph – open the profiler, click on an SQL query, and see the stack trace to identify the source.
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningView iterates over a collection and accesses a `belongs_to` or `has_many` association without the controller using `includes`.
  • warningSerializers (e.g., Jbuilder, ActiveModelSerializers) that lazily load associations per record.
  • warningCallbacks or after_initialize hooks that trigger association loads.
  • warningNested associations: loading `users` then `posts` then `comments` without a multi-level `includes`.
  • warningCustom scopes that reference associations without eager loading in the query.
  • warningUsing `pluck` or `select` on an association after a loop: `user.posts.pluck(:title)` inside a loop causes N queries.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildReplace `.all` with `.includes(:association)` in the controller: `Post.includes(:comments)` loads all comments in 2 queries.
  • buildUse `preload` to separate queries (always 2 queries) when you don't need joins or conditions on the association.
  • buildUse `eager_load` to force a LEFT OUTER JOIN when you need to filter/order by the association's columns.
  • buildIn views, consider caching fragments to avoid repeated association loads.
  • buildUse `includes` with nested associations: `User.includes(posts: :comments)` to eager load two levels deep.
  • buildFor collections inside loops, move the eager load to the controller and pass the loaded collection to the view.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter adding `includes`, check the development log: the repeated queries should be gone, replaced by a single query with `WHERE id IN (...)`.
  • verifiedUse rack-mini-profiler: the query count should drop dramatically (e.g., from 101 to 3).
  • verifiedRun Bullet again: no N+1 warnings should appear.
  • verifiedBenchmark the page load time before and after: `curl -w %{time_total} -o /dev/null -s <url>`.
  • verifiedCheck the database server slow query log: the number of queries per page should decrease.
  • verifiedWrite a test: `assert_queries(2) { get :index }` using `ActiveSupport::TestCase` and `assert_queries` helper.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningUsing `includes` with a `where` clause on the associated table without understanding that it forces a LEFT JOIN and can cause duplicates.
  • warningOver-eager loading all associations blindly, which can load unnecessary data and slow down queries.
  • warningForgetting to eager load nested associations (e.g., `includes(:posts)` but not `includes(posts: :comments)`).
  • warningUsing `joins` instead of `includes` when you only need to load the association data, causing extra queries.
  • warningCalling `.size` or `.count` on an association inside a loop – use `.size` which uses cached count from `length` if loaded.
  • warningAssuming that adding `includes` in a scope will propagate to the parent query – it does not; you must eager load at the controller level.
( 07 )War story

The 101-Query Dashboard That Took Down Staging

Senior Backend EngineerRails 5.2, PostgreSQL, Puma, New Relic

Timeline

  1. 09:15Deploy a new dashboard that lists 100 projects with their recent tasks and assignees.
  2. 09:20Staging becomes unresponsive; page takes 8 seconds to load.
  3. 09:22Check New Relic: 101 SQL queries per request, average response time 7.5s.
  4. 09:25Tail staging log: 100 identical `SELECT "tasks".* FROM "tasks" WHERE "tasks"."project_id" = $1` queries.
  5. 09:30Open the dashboard view: `@projects.each { |p| p.tasks.each { |t| t.assignee.name } }`.
  6. 09:32Controller: `@projects = Project.all` – no `includes`.
  7. 09:35Add `includes(:tasks)` to controller, but still see extra queries for assignee.
  8. 09:40Fix with `Project.includes(tasks: :assignee)`.
  9. 09:42Verify: log shows 3 queries (projects, tasks, assignees). Page loads in 0.3s.

The day started normally with a deploy of a new dashboard feature. I had added a page showing all active projects with their latest tasks and the task assignees. The view looked straightforward: loop over projects, loop over tasks, display assignee name. I didn't think much about performance because we had fewer than 50 projects in staging.

But as soon as the deploy hit staging, the page was unusable. It took over 8 seconds to load. New Relic showed a staggering 101 SQL queries per request. I immediately tailed the Rails log and saw the pattern: a `SELECT tasks` for every single project. Classic N+1. The view was lazy loading tasks one by one. I quickly added `includes(:tasks)` to the controller and redeployed.

That reduced queries to 2 per request? No, it dropped to 2 + 100 queries for assignee. I had forgotten that inside the tasks loop, we also called `t.assignee.name`. That was another N+1. I updated the eager load to `Project.includes(tasks: :assignee)`. After that, the log showed only 3 SQL queries. The page loaded in 0.3 seconds. Lesson learned: always eager load all associations used in views, and check nested loops too.

Root cause

The controller loaded `@projects = Project.all` without eager loading, causing N+1 queries for tasks and assignees in the view.

The fix

Changed controller to `@projects = Project.includes(tasks: :assignee)` and verified with Bullet.

The lesson

Always use `includes` for any association accessed inside a collection loop, especially in nested loops. Use Bullet to catch them automatically.

( 08 )Understanding ActiveRecord's Lazy Loading and Eager Loading

ActiveRecord associations are lazy by default: `post.comments` only hits the database when you actually call it. This is efficient for single records but disastrous for collections. When iterating over 100 posts, each `post.comments` triggers a separate query, resulting in 1 + N queries.

Eager loading via `includes` uses a strategy: it runs one query to load the parent records, then another query to load all associated records with a `WHERE id IN (...)`, and then associates them in memory via the foreign key. `preload` always uses two queries, while `eager_load` uses a LEFT OUTER JOIN. `includes` decides based on whether you add conditions on the association.

The key insight is that eager loading doesn't just reduce queries—it also prevents the N+1 anti-pattern by pre-loading all associated records before the iteration starts. If you need to filter or sort by the association's columns, you must use `eager_load` or `joins` with `includes`.

( 09 )Using Bullet to Automatically Detect N+1 Queries

Bullet is a gem that monitors your application in development and warns you when an N+1 is detected. Install it with `gem 'bullet'`, then configure: `Bullet.enable = true`, `Bullet.rails_logger = true`. Optionally add `Bullet.alert = true` for JavaScript alerts in the browser.

When Bullet detects an N+1, it logs a warning like `AVOID N+1 QUERIES: Post => [:comments]` and shows the file and line number where the lazy load happened. It also suggests the fix: `Add includes(:comments) to your query.`

Bullet can also detect unused eager loading (eager loading associations that are never used). This helps keep your queries lean. Run your test suite with Bullet enabled to catch N+1s before they reach production.

( 10 )Advanced: N+1 in Serializers and Background Jobs

N+1 queries often hide in serializers like Jbuilder or ActiveModelSerializers. If you render a collection of JSON with each record's associated data, the serializer will lazy load the association per record. For Jbuilder, use `json.array! @posts, partial: 'post', as: :post` and ensure the partial accesses associations that were eager loaded in the controller.

Background jobs (e.g., Sidekiq) can also suffer from N+1. If you iterate over a large batch of records and access associations, the same pattern occurs. Always `includes` associations before iterating in a job.

A subtle case: using `includes` with a scope that uses `where` on the associated table can cause unexpected results. For example, `Post.includes(:comments).where(comments: { approved: true })` forces a LEFT JOIN and will return only posts that have approved comments, effectively an inner join. To avoid this, use `preload` and then filter in Ruby, or use `joins` for filtering and `includes` for loading.

Frequently asked questions

What's the difference between `includes`, `preload`, and `eager_load`?

`preload` always loads associated data in a separate query (2 queries total). `eager_load` uses a LEFT OUTER JOIN (1 query). `includes` is smart: it uses `preload` by default, but if you add a `where` or `order` clause referencing the association, it switches to `eager_load`. Use `preload` for simple loading, `eager_load` when you need to filter/order by the association's columns.

How can I test for N+1 queries in my test suite?

Use the `assert_queries` helper from `ActiveRecord::TestCase`. For example: `assert_queries(3) { get :index }` ensures the action runs exactly 3 queries. Alternatively, use Bullet in test mode: `config.after_initialize { Bullet.enable = true }` and it will fail tests if N+1 is detected.

Does eager loading always improve performance?

Not always. If you have a small number of records or rarely access the association, eager loading can add unnecessary overhead (extra queries or joins). Profile first. For large collections where you access the association for every record, eager loading is almost always faster.

Can N+1 happen with `has_many :through`?

Yes. For example, `User has_many :posts, through: :memberships`. If you iterate over users and call `user.posts`, you'll get N+1 unless you `includes(:posts)` or `includes(:memberships)`. Always eager load the through association or the target association.