Rails 8 Strict Loading: Stop N+1 at the Source

N+1 queries silently destroy Rails application performance. While eager loading fixes individual cases, strict loading prevents them from happening in the first place. Rails 8 offers multiple strict loading modes that catch these issues during development, not after users complain about slow pages.

The Problem: Silent Performance Killers

Consider a typical blog application. Loading posts with their authors seems straightforward until the logs reveal dozens of queries firing where one should suffice. Traditional solutions involve sprinkling includes throughout the codebase, but this reactive approach misses queries until they cause problems.

Strict loading flips this model. Instead of hoping developers remember to eager load, Rails raises exceptions when lazy loading occurs. This surfaces N+1 queries immediately during development and testing.

Enabling Strict Loading Globally

The most aggressive approach enables strict loading for all models application-wide. This configuration ensures no lazy loading happens anywhere:

# config/environments/development.rb
Rails.application.configure do
  # Raise errors when lazy loading associations
  config.active_record.strict_loading_by_default = true
  
  # Also catch violations in test environment
end

# config/environments/test.rb
Rails.application.configure do
  config.active_record.strict_loading_by_default = true
end

With this configuration, any attempt to lazy load an association raises ActiveRecord::StrictLoadingViolationError. This aggressive stance works well for new applications or teams committed to eliminating all N+1 queries.

Model-Level Strict Loading

For existing applications, enabling strict loading per-model provides a gradual migration path. Start with models that cause the most performance issues:

# app/models/post.rb
class Post < ApplicationRecord
  # Enable strict loading for all Post queries
  self.strict_loading_by_default = true
  
  belongs_to :author, class_name: 'User'
  has_many :comments
  has_many :taggings
  has_many :tags, through: :taggings
  has_one :featured_image, class_name: 'Image', as: :imageable
end

# app/models/user.rb
class User < ApplicationRecord
  # Leave strict loading off while migrating
  # self.strict_loading_by_default = true
  
  has_many :posts
  has_many :comments
end

This approach allows teams to tackle one model at a time, fixing eager loading issues before moving to the next model.

Query-Level Strict Loading

Sometimes strict loading should apply only to specific queries. The strict_loading method enables this granular control:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    # Strict loading only for this query
    @posts = Post.strict_loading
                 .includes(:author, :tags)
                 .where(published: true)
                 .order(created_at: :desc)
                 .limit(20)
  end
  
  def show
    # Load specific associations, strict load everything else
    @post = Post.strict_loading
                .includes(:author, :comments, :tags)
                .find(params[:id])
  end
  
  def edit
    # No strict loading needed for simple edit form
    @post = Post.find(params[:id])
  end
end

This pattern works well when certain views need more associations than others. The controller explicitly declares what gets loaded, and strict loading catches any missed associations.

Association-Level Configuration

Some associations should always be strict loaded, regardless of global settings. Configure this directly on the association:

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :customer
  
  # Always require explicit loading for line items
  has_many :line_items, strict_loading: true
  
  # Expensive association - force eager loading
  has_many :shipments, strict_loading: true
  has_many :tracking_events, through: :shipments, strict_loading: true
  
  # Lightweight association - lazy loading acceptable
  has_many :notes, strict_loading: false
end

# app/models/line_item.rb
class LineItem < ApplicationRecord
  belongs_to :order
  belongs_to :product, strict_loading: true
  
  # Variant lookup can be expensive
  belongs_to :variant, strict_loading: true
end

This configuration documents which associations are expensive and prevents accidental lazy loading even when global strict loading is disabled.

Handling Strict Loading Violations

When strict loading catches a violation, the exception message identifies exactly which association triggered it:

# Example error message:
# ActiveRecord::StrictLoadingViolationError:
# `Post` is marked for strict loading. The `comments` association
# cannot be lazily loaded.

# Fix by including the association:
@posts = Post.strict_loading.includes(:comments).all

# Or preload if includes causes issues:
@posts = Post.strict_loading.preload(:comments).all

The error message tells developers exactly what association needs eager loading, making fixes straightforward.

Strict Loading Modes

Rails 8 supports two strict loading modes that control behavior differently:

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  
  # :all - raises on any lazy load (default)
  # :n_plus_one_only - only raises when iterating
  self.strict_loading_mode = :n_plus_one_only
end

# config/environments/development.rb
Rails.application.configure do
  config.active_record.strict_loading_by_default = true
  
  # Only catch actual N+1 patterns, not single lazy loads
  config.active_record.strict_loading_mode = :n_plus_one_only
end

The :n_plus_one_only mode provides a middle ground. Loading a single record's association won't raise an error, but iterating through a collection and lazy loading associations will. This catches the worst performance offenders while allowing occasional single-record lazy loads.

Testing Strict Loading Compliance

Automated tests can verify that controllers properly eager load associations:

# spec/requests/posts_spec.rb
RSpec.describe "Posts", type: :request do
  describe "GET /posts" do
    let!(:posts) { create_list(:post, 3, :with_author, :with_tags) }
    
    it "eager loads associations without strict loading violations" do
      # Enable strict loading for this test
      Post.strict_loading_by_default = true
      
      expect {
        get posts_path
      }.not_to raise_error
    ensure
      Post.strict_loading_by_default = false
    end
  end
end

# spec/support/strict_loading_helper.rb
module StrictLoadingHelper
  def with_strict_loading(*models)
    original_values = models.map { |m| [m, m.strict_loading_by_default] }.to_h
    models.each { |m| m.strict_loading_by_default = true }
    yield
  ensure
    original_values.each { |model, value| model.strict_loading_by_default = value }
  end
end

RSpec.configure do |config|
  config.include StrictLoadingHelper
end

This helper makes it easy to test strict loading compliance across the test suite without affecting other tests.

Production Considerations

Strict loading should generally stay disabled in production. A missed eager load causing an exception is worse than a slow query:

# config/environments/production.rb
Rails.application.configure do
  # Never enable strict loading in production
  config.active_record.strict_loading_by_default = false
end

Instead, rely on development and test environments to catch violations, and use query logging or APM tools to identify any that slip through.

Summary

Strict loading transforms N+1 query prevention from a reactive debugging task into proactive development practice. Start with query-level strict loading on critical paths, expand to model-level configuration for problem areas, and consider application-wide enforcement for new projects. Combined with proper test coverage, strict loading ensures eager loading issues surface before reaching production.

10 claps
← Back to Blog