Avoiding N+1 Queries with Rails Strict Loading
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
.
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
- Ignore strict loading and continue to use the bullet gem (and be diligent about avoiding N+1 queries)
- 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.