35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails N+1 Queries: How to Find, Fix, and Prevent Them in Production

Rails N+1 Queries: How to Find, Fix, and Prevent Them in Production

TTB Software
rails

An N+1 query happens when your code loads a collection of records, then executes one additional query for each record to fetch an association. Load 100 orders, fire 100 queries to get each order’s customer. That’s 101 queries where 1 or 2 would do.

This is the single most common performance problem in Rails applications. I’ve seen N+1s add 3-8 seconds to page loads in production apps processing a few hundred records. The fix usually takes under a minute once you find it.

What an N+1 Actually Looks Like

Here’s a controller action that looks innocent:

# app/controllers/orders_controller.rb
def index
  @orders = Order.where(status: :recent).limit(50)
end

And a view that triggers the problem:

<%# app/views/orders/index.html.erb %>
<% @orders.each do |order| %>
  <tr>
    <td><%= order.customer.name %></td>
    <td><%= order.product.title %></td>
    <td><%= order.total %></td>
  </tr>
<% end %>

Your logs will show this:

SELECT "orders".* FROM "orders" WHERE "orders"."status" = 'recent' LIMIT 50
SELECT "customers".* FROM "customers" WHERE "customers"."id" = 1
SELECT "products".* FROM "products" WHERE "products"."id" = 42
SELECT "customers".* FROM "customers" WHERE "customers"."id" = 2
SELECT "products".* FROM "products" WHERE "products"."id" = 17
-- ... 96 more queries

That’s 101 queries. Each one has network overhead to the database, query parsing time, and result serialization. Even if each query takes only 2ms, you’re spending 200ms on what could be a single 5ms query.

The Three Fixes: includes, preload, and eager_load

Rails gives you three methods to eliminate N+1 queries. They’re not interchangeable.

includes — The Smart Default

@orders = Order.where(status: :recent)
               .includes(:customer, :product)
               .limit(50)

includes lets Rails decide the loading strategy. It uses two separate queries by default (like preload), but switches to a LEFT OUTER JOIN (like eager_load) if you reference the association in a where or order clause.

This produces:

SELECT "orders".* FROM "orders" WHERE "orders"."status" = 'recent' LIMIT 50
SELECT "customers".* FROM "customers" WHERE "customers"."id" IN (1, 2, 3, ...)
SELECT "products".* FROM "products" WHERE "products"."id" IN (42, 17, 8, ...)

Three queries total. Down from 101.

preload — Always Separate Queries

@orders = Order.where(status: :recent)
               .preload(:customer, :product)
               .limit(50)

preload always fires separate queries. Use it when you know you don’t need to filter or sort by the association’s columns. It’s slightly more efficient than a JOIN for simple cases because each query hits a single table’s indexes.

eager_load — Always JOIN

@orders = Order.where(status: :recent)
               .eager_load(:customer)
               .where(customers: { vip: true })
               .limit(50)

eager_load uses LEFT OUTER JOIN, loading everything in a single query. Use it when you need to filter or sort by the associated table’s columns.

SELECT "orders"."id" AS t0_r0, "orders"."status" AS t0_r1, ...
       "customers"."id" AS t1_r0, "customers"."name" AS t1_r1, ...
FROM "orders"
LEFT OUTER JOIN "customers" ON "customers"."id" = "orders"."customer_id"
WHERE "orders"."status" = 'recent' AND "customers"."vip" = TRUE
LIMIT 50

The trade-off: JOINs return wider result sets with duplicated data when associations are one-to-many. If an order has 5 line items, the JOIN returns 5 rows per order. Rails deduplicates them in memory, but you’re transferring more data from the database.

When to Use Which

Situation Method
Simple association loading, no filtering includes or preload
Filtering/sorting by association columns eager_load
Polymorphic associations preload (JOINs don’t work with polymorphic)
Deep nested associations includes with hash syntax
You want explicit control preload or eager_load directly

Nested Eager Loading

Real applications have nested associations. Rails handles this with hash syntax:

@orders = Order.includes(
  customer: :address,
  line_items: { product: :category }
).where(status: :recent).limit(50)

This fires 6 queries instead of potentially hundreds:

SELECT "orders".* FROM "orders" WHERE ...
SELECT "customers".* FROM "customers" WHERE "customers"."id" IN (...)
SELECT "addresses".* FROM "addresses" WHERE "addresses"."customer_id" IN (...)
SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" IN (...)
SELECT "products".* FROM "products" WHERE "products"."id" IN (...)
SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (...)

Finding N+1s with Bullet

The Bullet gem detects N+1 queries in development and test environments. It’s been around since 2009 and remains the standard tool for this.

Add it to your Gemfile:

# Gemfile
group :development, :test do
  gem "bullet"
end

Configure it in your environment files:

# config/environments/development.rb
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.alert = true          # JavaScript popup in browser
    Bullet.bullet_logger = true  # Log to log/bullet.log
    Bullet.console = true        # Browser console warnings
    Bullet.rails_logger = true   # Add to Rails log
    Bullet.add_footer = true     # Show warnings in page footer
  end
end

For your test suite, configure it to raise errors so N+1s fail your CI pipeline:

# config/environments/test.rb
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.raise = true  # Raise exceptions on N+1
  end
end

If you’re using Minitest with fixtures, add the Bullet hooks:

# test/test_helper.rb
class ActiveSupport::TestCase
  setup { Bullet.start_request }
  teardown { Bullet.end_request }
end

Bullet also detects unused eager loading — when you preload an association that’s never actually accessed. This wastes memory and query time. It’s a less obvious performance issue but worth fixing.

strict_loading: Rails’ Built-in Prevention (Rails 6.1+)

Since Rails 6.1, you can make lazy loading raise an error instead of silently firing queries. This catches N+1s without any gems.

Per-query strict loading

@orders = Order.strict_loading.where(status: :recent).limit(50)

# This now raises ActiveRecord::StrictLoadingViolationError:
@orders.first.customer

You must explicitly eager load everything you need:

@orders = Order.strict_loading
               .includes(:customer, :product)
               .where(status: :recent)
               .limit(50)

# This works fine:
@orders.first.customer

Per-association strict loading

class Order < ApplicationRecord
  belongs_to :customer
  has_many :line_items, strict_loading: true  # Only this association
end

Application-wide strict loading

In Rails 7+, you can enable it globally:

# config/application.rb
config.active_record.strict_loading_by_default = true

This is aggressive. Every lazy load raises an error. It’s useful for new applications where you want to enforce eager loading from day one. For existing apps, start with per-model or per-query strict loading and work through violations gradually.

I’ve enabled this on a mid-sized app (around 80 models) and spent about two days fixing the resulting errors. After that, zero N+1s slipped through. The upfront cost was worth the ongoing prevention.

strict_loading with :n_plus_one_only mode (Rails 7.1+)

Rails 7.1 added a more practical mode that only flags actual N+1 patterns, not every lazy load:

class Order < ApplicationRecord
  self.strict_loading_mode = :n_plus_one_only
end

This lets single-record lazy loads work fine (Order.find(1).customer) but raises on collection lazy loads (Order.all.each { |o| o.customer }). It’s less noisy and catches the queries that actually hurt performance.

The Counter Cache Pattern

A specific N+1 variant happens when you display counts:

<% @categories.each do |category| %>
  <td><%= category.products.count %></td>  <%# Fires COUNT query per row %>
<% end %>

Instead of eager loading, use a counter cache:

# migration
add_column :categories, :products_count, :integer, default: 0, null: false

# Backfill existing data
Category.find_each do |cat|
  Category.reset_counters(cat.id, :products)
end
class Product < ApplicationRecord
  belongs_to :category, counter_cache: true
end

Now category.products_count reads from a column — no query at all. Rails keeps the count updated automatically when products are created or destroyed.

Monitoring N+1s in Production

Development-time detection misses N+1s that only appear with real data or in code paths you didn’t test. For production monitoring, use Prosopite or your APM tool.

Prosopite is lighter than Bullet and designed for production use:

# Gemfile
gem "prosopite"

# config/initializers/prosopite.rb
Prosopite.enabled = Rails.env.production?
Prosopite.min_n_queries = 3  # Only flag when 3+ similar queries fire

If you’re using OpenTelemetry for observability, your traces will show repeated database spans with identical query patterns — another way to spot N+1s in production traces.

Most APM services (Scout, Skylight, New Relic) also detect and flag N+1 patterns automatically. Scout in particular has good N+1 detection that shows the source code location.

Performance: How Bad Can It Get?

I benchmarked a typical N+1 scenario on a PostgreSQL 16 database with 10,000 orders, each with a customer and 3 line items. All on the same machine (no network latency, which would make it worse):

Approach Queries Time
N+1 (no eager loading) 10,001 4,200ms
includes(:customer) 2 18ms
eager_load(:customer) 1 22ms
preload(:customer, line_items: :product) 4 45ms

The N+1 version is 230x slower. In production with network latency between app and database servers, that multiplier gets worse. Each query adds 0.5-2ms of network round-trip time on top of execution time.

Common Traps

Serializers and API responses. If you use ActiveModel Serializers, Blueprinter, or similar, the serializer accesses associations that trigger lazy loads. Always eager load in the controller before serialization.

Callbacks accessing associations. An after_save callback that touches self.user.profile fires a query per save in bulk operations. Move association access out of callbacks or preload before bulk operations.

Scopes hiding eager loads. A scope like scope :with_details, -> { includes(:customer, :product) } can mask what’s being loaded. Name these scopes clearly.

Pagination doesn’t save you. “We only show 25 per page” still means 25 extra queries. Small N+1s add up across thousands of requests per minute.

FAQ

How do I fix N+1 queries in Rails API-only applications?

The fix is identical: add includes, preload, or eager_load to your controller queries before the serializer touches them. API apps hit N+1s more often because serializers tend to access deep association chains. Use strict_loading on your API controllers to catch them early: @orders = Order.strict_loading.includes(:customer, line_items: :product).where(...).

Does includes slow down queries when I don’t need the association?

Yes. Bullet detects this as “unused eager loading.” If you includes(:customer) but never access order.customer, you’ve loaded data for nothing. Only eager load what you actually use. Conditional eager loading based on request parameters (like sparse fieldsets in JSON:API) can help.

Should I use strict_loading in production?

Use strict_loading_mode: :n_plus_one_only in production with a custom violation handler that logs instead of raising. You don’t want user-facing errors, but you want visibility. Full strict_loading with raised errors belongs in development and CI only.

What about N+1s in GraphQL APIs?

GraphQL’s nested field resolution makes N+1s especially common. Use dataloader or the built-in GraphQL::Dataloader in the graphql-ruby gem. These batch association loads across resolver calls automatically. Standard includes doesn’t work well with GraphQL because you don’t know which fields the client requested until execution time.

Can N+1 queries crash my database?

They won’t crash PostgreSQL or MySQL directly, but they can exhaust your connection pool. If each web request fires 500 queries and holds a connection for 2 seconds instead of 20ms, you run out of connections fast. I’ve seen a 50-connection pool get saturated by 25 concurrent users because of N+1s in a dashboard page.

#rails #activerecord #n-plus-one #performance #bullet #strict-loading #database
T

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation