N+1 is a serious problem that can bring your app to a crawl. This issue is not always immediately evident and often surfaces after a while when you have more data. The good news for Rails developers is that this is easily solved with preload(), includes(), and eager_load() methods.
eager_load()loads the associated records with a single query using a LEFT OUTER JOIN.User.eager_load(:posts)Would result in a following SQL:
SELECT users.*, posts.* FROM users LEFT OUTER JOIN posts ON posts.user_id = users.idpreload()loads in multiple queries: one for the main records and one for each association.User.preload(:posts)Would result in a following SQL:
SELECT * FROM users SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...)includes()is the most commonly used method for eager loading associations. It’s smart enough to decide between two strategies:- If no where clause references the included tables, it uses
preload(). - If there’s a where clause referencing the included tables, it switches to
eager_load().
- If no where clause references the included tables, it uses
It’s harder to notice places where we’ve introduced N+1 queries, and this is an area that many people try to figure out. First, we had the bullet gem, but its biggest issue is the many false positives it tends to highlight. Nowadays, prosopite seems to be the coolest kid on the block, promising no false positives.
My controversial opinion is that the strict_loading feature in Rails is all we need these days to detect most of these issues. One just needs to learn to cook it right!
Enter strict_loading
The main purpose of strict_loading is to enforce eager loading of associations and preventing accidental N+1 queries. When strict_loading is enabled, Rails will raise an error (ActiveRecord::StrictLoadingViolationError) if you try to load an association that wasn’t explicitly preloaded.
There are multiple ways to enable strict_loading. Let me walk you through all the methods I know off.
Individual queries
u = User.where(..).includes(:posts).strict_loading
# raises ActiveRecord::StrictLoadingViolationError
u.posts.first.category
Individual records
u = User.strict_loading.find(..)
# raises ActiveRecord::StrictLoadingViolationError
u.posts
u = User.find(..)
u.strict_loading!
# Or you can even disable it
u = User.find(..)
u.strict_loading!(false)
Entire associations
class User < ActiveRecord
has_many :posts, strict_loading: true
end
Entire models
class User < ActiveRecord
self.strict_loading_by_default = true
end
Entire applications
config.active_record.strict_loading_by_default = true
n_plus_one_only mode
The :n_plus_one_only mode is designed to specifically target and prevent N+1 query problems while still allowing some flexibility in association loading. It allows lazy loading of associations when dealing with a single record. But It raises an ActiveRecord::StrictLoadingViolationError when it detects an N+1 query pattern.
users = User.all.strict_loading!(mode: :n_plus_one_only)
# This works fine (lazy loading for a single record)
first_user = users.first
first_user.posts.to_a # This is allowed
# This will raise an error (N+1 query detected)
users.each do |user|
user.posts.to_a # This will raise ActiveRecord::StrictLoadingViolationError
end
In Rails 7.2 (turns out this change landed in master, but didn’t land with rails 7.2), it’s now possible to enable this mode for the entire application. This was the last puzzle piece missing to make this functionality widely usable.
config.active_record.strict_loading_mode = :n_plus_one_only
Integrating
Even in the smallest applications, it’s difficult to enable strict_loading_by_default for the entire application immediately. It’s always better to introduce strict_loading gradually. I propose the following 3-step approach:
- As a first step, start marking individual queries with
.strict_loadingon your app’s main pages - those that have many graphs and data points. Once obvious offenders are eliminated, you need to cast a wider net. - Identify “god models” in your application and mark those for
strict_loading_by_default. In a large blog application, this would probably be theUserandPostmodels. - The final boss of strict loading would be to enable
strict_loading_by_defaultfor the entire application. But even here, you can find ways to ease into it.
# config/application.rb
config.active_record.strict_loading_by_default = true
# Will only raise, if multiple records have to loaded from database
config.active_record.strict_loading_mode = :n_plus_one_only
# instead of raising an error - emits a log message
config.active_record.action_on_strict_loading_violation = :log
When in :log mode, Rails emits an event for every lazy load. We can listen for those and make our own structured log message:
# config/initializers/strict_loading.rb
ActiveSupport::Notifications.subscribe(
"strict_loading_violation.active_record"
) { |_name, _started, _finished, _unique_id, data|
model = data.fetch(:owner)
ref = data.fetch(:reflection)
Rails.logger.warn({
event: "strict_loading_violation",
model: model.name,
association: ref.name,
trace: caller
}.to_json)
# Add example with AppSignal::EventFormatter.
}
Final thoughts
strict_loading was announced with a lot of fanfare, but most Ruby developers quickly forgot about it and continued with their regular usage of bullet and/or prosopite. However, if strict_loading_mode could be set for entire app, all the features are in place to make this feature shine. It would finally feel feature-complete, to the point where I would consider removing prosopite from all my projects and have one less dependency in Gemfiles.