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.id
    
  • preload() 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:
    1. If no where clause references the included tables, it uses preload().
    2. If there’s a where clause referencing the included tables, it switches to eager_load().

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:

  1. As a first step, start marking individual queries with .strict_loading on 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.
  2. Identify “god models” in your application and mark those for strict_loading_by_default. In a large blog application, this would probably be the User and Post models.
  3. The final boss of strict loading would be to enable strict_loading_by_default for 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.