Avoiding N+1 Queries with Rails Strict Loading

May 28, 2025

Active Record makes it easy to introduce N+1 queries into your Rails applications. The convenience of easily adding associations to your models means that great care is needed to avoid N+1 queries. I was therefore excited to see that Rails 6.1 introduced strict_loading to help alleviate some of these problems. I had previously used the bullet gem which adds visibility around these issues but built-in guardrails are a great addition to Rails. I came across this deep dive by Jordan Hollinger while researching strict loading, and it helped clarify some of the finer points—especially around n_plus_one_only.

High Speed

Strict loading can be enabled at various levels: model, association, individual record, query, or across the entire application. Strict loading can be enabled in two modes - all and n_plus_one_only. You can also configure the action that is taken when a strict loading violation occurs, either raising an error or logging a warning.

Individual Queries

customers = Customer
  .preload(:addresses)
  .where(id: ...)
  .strict_loading

customers.each { |customer| customer.addresses.each(&:inspect) }
# No errors, since addresses are pre-loaded

customers.each { |customer| customer.statements.each(&:inspect) }
# ActiveRecord::StrictLoadingViolationError

You can also disable strict loading, but there doesn’t seem to be an option to specify the mode.

customers = Customer
  .strict_loading(false)
  .where(id: ...)

customers.each { |customer| customer.statements.each(&:inspect) }
# No errors (despite N+1 queries)

Individual Records

customer = Customer.find(...)
customer.strict_loading!
customer.statements.first
# ActiveRecord::StrictLoadingViolationError

customer.strict_loading!(mode: :n_plus_one_only)
customer.statements.first
# No errors

Model Level

class Customer < ApplicationRecord
  self.strict_loading_by_default = true

  # New option in Rails 8.0
  self.strict_loading_mode = :n_plus_one_only # or :all
end

customer = Customer.find(...)
customer.addresses.each(&:inspect) # ActiveRecord::StrictLoadingViolationError

Association Level

class Customer < ApplicationRecord
  has_many :addresses, strict_loading: true
end

customer = Customer.find(...)
customer.addresses.each(&:inspect) # ActiveRecord::StrictLoadingViolationError

Application Level

# config/application.rb
config.active_record.strict_loading_by_default = true # defaults to false
config.active_record.action_on_strict_loading_violation = :log # defaults to :raise

# New option in Rails 8.0
config.active_record.strict_loading_mode = :n_plus_one_only # defaults to :all

The different ways to enable strict loading make sense to me - if you’re creating a new application you will probably want to opt-in by default, but if you’re working on an existing application you might want to enable it by default and then selectively opt-out models, individual queries, or records. The association level doesn’t really make sense to me. I can’t see a compelling use case for it.

n_plus_one_only Mode

The n_plus_one_only mode can be confusing in practice. To illustrate how it works I have to create a few different associations.

class Customer < ApplicationRecord
  has_many :addresses
  has_many :statements
end

class Statement < ApplicationRecord
  belongs_to :customer
  has_many :entries, class_name: 'StatementEntry'
end

class StatementEntry < ApplicationRecord
  belongs_to :statement
end

Then I have also enabled strict loading across the entire application in n_plus_one_only mode.

statements = Statement.where(id: ...)
statements.each { |statement| statement.customer.name }
  Statement Load (2.0ms)  SELECT "statements".* FROM "statements" WHERE "statements"."id" BETWEEN ? AND ?  [["id", 1], ["id", 5]]
  Customer Load (0.1ms)  SELECT "customers".* FROM "customers" WHERE "customers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Customer Load (0.1ms)  SELECT "customers".* FROM "customers" WHERE "customers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Customer Load (0.0ms)  SELECT "customers".* FROM "customers" WHERE "customers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Customer Load (0.0ms)  SELECT "customers".* FROM "customers" WHERE "customers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Customer Load (0.0ms)  SELECT "customers".* FROM "customers" WHERE "customers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

This is a textbook example of when you’d expect strict loading to raise an error. Similarly, let’s load all the entries for each statement (since that’s a has_many association).

statements = Statement.where(id: ...)
statements.each { |statement| statement.entries.map(&:description).join(', ') }
  Statement Load (1.1ms)  SELECT "statements".* FROM "statements" WHERE "statements"."id" BETWEEN ? AND ?  [["id", 1], ["id", 5]]
  StatementEntry Load (0.0ms)  SELECT "statement_entries".* FROM "statement_entries" WHERE "statement_entries"."statement_id" = ?  [["statement_id", 1]]
  StatementEntry Load (0.0ms)  SELECT "statement_entries".* FROM "statement_entries" WHERE "statement_entries"."statement_id" = ?  [["statement_id", 2]]
  StatementEntry Load (0.0ms)  SELECT "statement_entries".* FROM "statement_entries" WHERE "statement_entries"."statement_id" = ?  [["statement_id", 3]]
  StatementEntry Load (0.1ms)  SELECT "statement_entries".* FROM "statement_entries" WHERE "statement_entries"."statement_id" = ?  [["statement_id", 4]]
  StatementEntry Load (0.0ms)  SELECT "statement_entries".* FROM "statement_entries" WHERE "statement_entries"."statement_id" = ?  [["statement_id", 5]]

Note that both of these snippets correctly raise an error when I change the strict_loading_mode to :all. I’m not the first one to notice this behavior, there is a Rails Github Issue that funnily enough flags Strict Loading n_plus_one_only doesn't catch N+1 problem. The underlying idea behind n_plus_one_only appears to be avoiding errors when working with a single record, since eager-loading in that scenario could mean that you are loading data unnecessarily.

Consider the following snippet:

statement = Statement.find(...)
statement.entries.map(&:description).join(', ')

This will raise an error if strict_loading_mode is set to :all, but not if it is set to :n_plus_one_only. That makes sense to me - loading the data later on doesn’t make the query worse, but always pre-loading it could mean we’re loading unnecessary data - for example, if we pass the statement to a view that conditionally renders the entries. However, given how :n_plus_one_only behaves in other scenarios I don’t see how it makes sense to enable it globally. The problem seems to be that Rails simply doesn’t know whether you are accessing associations from within the context of a single record or a collection of records.

Conclusion

If you’re starting a new Rails application I think the only sensible options are

  1. Ignore strict loading and continue to use the bullet gem (and be diligent about avoiding N+1 queries)
  2. Enable strict loading globally in :all mode

I can maybe see a use case for enabling it globally and then selectively disabling it for individual records where you explicitly know that you don’t want to pre-load unnecessary data. I also prefer using the raise option for violations instead of simply logging, but I can also understand that this might seem too risky for some.

A completely different option might be to use Occams Record which avoids N+1 queries by default. I haven’t tried this in a real Rails application yet, but I am eager to do so.

Rails