/ Tags: RAILS / Categories: RAILS

Strict Loading in Rails — Eliminating N+1 at the Framework Level

Every Rails team has the same conversation eventually. Someone adds bullet to the Gemfile, fixes the twelve N+1 queries it finds, and six weeks later there are eight new ones. Detection tools tell you about the problem after you’ve written it. Strict loading flips that around: it makes the lazy load itself an error, so the N+1 never reaches a code review, let alone production.

The Problem With Detect-and-Fix


Lazy loading associations is the default and it’s invisible. This looks completely normal:

Example:

def index
  @orders = Order.recent.limit(50)
end
<% @orders.each do |order| %>
  <%= order.customer.name %><%= order.line_items.count %> items
<% end %>

That’s 101 queries. Nothing in the controller hints at it, nothing in the view looks wrong, and the local database with forty rows returns in eight milliseconds. It ships. Three months later the table has two million rows and someone opens an incident.

The detection-tool approach depends on a human noticing a warning in a log during a request that happens to exercise the path. Strict loading removes the human from the loop.

Turning It On


strict_loading raises ActiveRecord::StrictLoadingViolationError the moment an association is loaded lazily on a marked record.

Example:

order = Order.strict_loading.find(42)
order.customer
# => ActiveRecord::StrictLoadingViolationError:
#    `Customer` called on `Order` is marked for strict_loading and cannot be lazily loaded.

Preload it and everything works:

order = Order.strict_loading.includes(:customer).find(42)
order.customer.name   # fine — already loaded

The scope propagates. Records reached through a strict-loaded parent are themselves strict-loaded, so you can’t sidestep the check by hopping one association deeper.

Per-record
order = Order.find(42)
order.strict_loading!
order.line_items   # raises
Per-association

Mark the associations that are expensive or frequently misused, and leave the cheap ones alone:

class Order < ApplicationRecord
  belongs_to :customer
  has_many :line_items, strict_loading: true
  has_many :audit_events, strict_loading: true
end
Per-model
class Order < ApplicationRecord
  self.strict_loading_by_default = true
end
Application-wide
# config/application.rb
config.active_record.strict_loading_by_default = true

Rolling It Out Without Breaking Production


Flipping strict_loading_by_default on an existing app raises in places you didn’t expect, and some of those places are payment callbacks. There’s a middle setting for exactly this.

Example:

# config/environments/production.rb
config.active_record.action_on_strict_loading_violation = :log

# config/environments/development.rb
# config/environments/test.rb
config.active_record.action_on_strict_loading_violation = :raise

With :log, violations go to the Rails logger at warn level with the model and association named, and the query still runs. Production stays up; you get a list.

The rollout that actually works:

  1. Set :log everywhere, ship, and collect a week of logs
  2. Fix the violations by frequency, highest first
  3. Switch development and test to :raise so new code can’t add more
  4. Once the log is quiet, switch production to :raise too

Step three is the one that matters. Everything before it is cleanup; that step is what stops the regression.

Scoping It to New Code Only


On a large legacy codebase, “fix every violation” is not a sprint you’ll get approved. Enable strict loading on the models you’re actively working on instead.

Example:

module StrictlyLoaded
  extend ActiveSupport::Concern

  included do
    self.strict_loading_by_default = true
  end
end

class Invoice < ApplicationRecord
  include StrictlyLoaded
end

Every new model includes the concern. Old models get it when someone next touches them. The boundary is explicit and greppable, and nobody has to fix two hundred violations before merging an unrelated feature.

What Strict Loading Does Not Catch


It’s worth being precise about the blast radius, because teams sometimes assume this removes the need to think about queries at all.

  • count on an unloaded association still issues a SELECT COUNT(*) — it’s not a lazy load of records, so it doesn’t raise. order.line_items.count in a loop remains N+1 and remains invisible. Use counter_cache or size on a loaded association.
  • Over-eager loading is untouched. includes(:everything) satisfies strict loading and can be far worse than the N+1 it replaced.
  • Queries in views via helpers that build fresh relations (Product.where(...) inside a partial) aren’t association loads at all.
  • joins without includes doesn’t populate the association, so accessing it afterwards still raises — which is correct, but surprises people who assume a join means loaded.

Pro-Tip: Turn strict loading on in the test environment first, before development. Tests exercise paths that manual clicking never reaches — background jobs, mailers, serializers, admin screens nobody has opened in a year. A test suite run with action_on_strict_loading_violation = :raise produces a complete inventory of your lazy loads in one command, which is a far better starting list than a week of production logs biased toward whatever your users happened to click. Fix them, then enable it in development so the next person feels the error while they’re writing the code rather than while reading a log.

Pairing It With Preload Strategies


Once violations raise, you have to be deliberate about loading, and the three strategies are not interchangeable.

Example:

# Two queries — separate SELECT for the association. Safe default.
Order.strict_loading.preload(:line_items)

# One query with LEFT OUTER JOIN — required when you filter on the association
Order.strict_loading.eager_load(:line_items).where(line_items: { status: "pending" })

# Rails decides between the two based on whether you reference the association
Order.strict_loading.includes(:line_items)

preload is right most of the time. Reach for eager_load only when you need to filter or order on the joined table — it produces one wide result set and duplicates parent columns across every child row, which gets expensive fast on has_many with wide parents.

Conclusion


Strict loading moves N+1 from something you detect to something you can’t write. That’s a structural fix, not a cleanup pass — the cost of a lazy load becomes a failing test rather than a slow request three months later. Enable it in test first for a full inventory, use :log in production while you work through the backlog, and let new models opt in by default. The remaining discipline is on the other side: preload deliberately, watch for count in loops, and don’t paper over a violation with includes(:everything).

FAQs


Q1: Which Rails version added strict loading?
strict_loading landed in Rails 6.1. The action_on_strict_loading_violation setting that lets you log instead of raise came in Rails 7.0, which is what makes a gradual rollout practical.

Q2: Does strict loading slow anything down?
No. It’s a flag checked when an association is accessed unloaded. There’s no extra query and no measurable overhead — the only cost is the exception itself, which you want.

Q3: How do I bypass it for one specific call?
Load the record without the scope, or use record.strict_loading!(false) to clear the flag on an instance. Do this sparingly and leave a comment explaining why, otherwise it becomes the default escape hatch.

Q4: Does it replace tools like bullet?
Largely, for lazy loads. bullet still adds value by flagging unused eager loads — the opposite problem — which strict loading has no opinion about. Many teams run both.

Q5: What happens with strict_loading inside a background job?
Same behaviour as anywhere else. Jobs are a common source of hidden N+1 because nobody watches their logs, which is a good argument for enabling it in the test environment where job specs will surface them.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More