Rails Strict Loading: How to Catch N+1 Queries Before They Hit Production
N+1 queries are the performance bug you don’t notice until your database is on fire. Rails has had includes and eager_load forever, but they require you to remember to use them. Strict loading flips that model: lazy loading becomes an error unless you explicitly allow it.
Available since Rails 6.1 and refined through Rails 7 and 8, strict loading catches missing eager loads at development time instead of letting them silently tank your production response times.
How Strict Loading Works
When strict loading is enabled on a record or association, any attempt to lazy-load an association raises ActiveRecord::StrictLoadingViolationError. Instead of silently firing off extra queries, your app tells you exactly where you forgot to eager load.
# Without strict loading — silently fires N+1 queries
posts = Post.all
posts.each { |p| p.comments.count } # N+1, no warning
# With strict loading — raises immediately
posts = Post.strict_loading.all
posts.each { |p| p.comments.count }
# => ActiveRecord::StrictLoadingViolationError:
# `Post` is marked for strict_loading. The Comment association
# named `:comments` cannot be lazily loaded.
That error tells you exactly which model and which association caused the problem. Fix it by adding the eager load:
posts = Post.strict_loading.includes(:comments).all
posts.each { |p| p.comments.count } # Single query, no error
Three Ways to Enable It
1. Per-Query with .strict_loading
The most targeted approach. Add it to specific queries you know are performance-sensitive:
# Only this query enforces strict loading
@dashboard_posts = Post.strict_loading
.includes(:author, :comments, :tags)
.where(published: true)
.limit(50)
This is useful for controller actions that render complex views where N+1 bugs tend to hide.
2. Per-Model as the Default
Set it on the model class to enforce strict loading on every query for that model:
class Post < ApplicationRecord
self.strict_loading_by_default = true
belongs_to :author
has_many :comments
has_many :tags, through: :taggings
end
Now Post.all returns strict-loading records automatically. Every controller and background job that touches Post must explicitly eager load associations or face the exception.
This is aggressive. It works well for models you know are frequently involved in N+1 issues — your core business objects with lots of associations.
3. Per-Association
Mark individual associations as strict:
class Post < ApplicationRecord
has_many :comments, strict_loading: true
has_many :audit_logs # This one can still lazy load
end
This is the most granular option. Use it when one specific association is expensive (like a has_many with thousands of records) but other associations on the same model are fine to lazy load.
Global Strict Loading in Development
The most practical setup for most teams: enable strict loading globally in development and test, but leave it off in production. This catches N+1 bugs during development without risking exceptions in production if something slips through.
In Rails 6.1 and 7.x, there’s no single config toggle for this. The common approach is an initializer:
# config/initializers/strict_loading.rb
if Rails.env.local? # .local? covers development + test in Rails 7.1+
ActiveRecord::Base.strict_loading_by_default = true
end
Rails 8 added config.active_record.strict_loading_by_default to the framework configuration, so you can set it directly:
# config/environments/development.rb
config.active_record.strict_loading_by_default = true
Strict Loading Mode: :log vs :raise
Rails 7.1 introduced strict_loading_mode, which lets you log violations instead of raising:
# Log instead of explode
ActiveRecord::Base.strict_loading_mode = :log
With :log mode, violations appear in your Rails log as warnings but don’t halt execution. This is useful during a migration period when you’re enabling strict loading on an existing codebase with hundreds of queries — you can see all the violations at once instead of fixing them one at a time.
# In your logs:
# WARN -- : `Post` is marked for strict_loading.
# The Comment association named `:comments` cannot be lazily loaded.
Once you’ve cleaned up the violations, switch to :raise for hard enforcement.
Handling the Transition
Enabling strict loading on a large codebase will surface dozens or hundreds of violations. Here’s a practical approach:
Step 1: Enable globally in development with :log mode. Run your test suite and note every violation.
Step 2: Sort violations by frequency. The queries that fire on every request matter more than the ones in a rarely-used admin panel.
Step 3: Fix the high-frequency ones first. Most fixes are straightforward includes calls in controllers or scopes:
# Before
def index
@posts = Post.where(published: true)
end
# After
def index
@posts = Post.includes(:author, :tags)
.where(published: true)
end
Step 4: For queries where eager loading doesn’t make sense (because the association is conditionally accessed), use strict_loading!(false) to explicitly opt out:
post = Post.strict_loading.find(params[:id])
post.strict_loading!(false) # Explicitly allow lazy loading for this record
Document why you opted out. A comment explaining “lazy-loaded because X is only accessed in 5% of requests” prevents the next developer from re-enabling it without understanding the tradeoff.
Step 5: Switch to :raise mode once violations are manageable.
Common Patterns That Break Under Strict Loading
A few patterns need adjustment:
Serializers that traverse associations. If you use ActiveModel Serializers or Blueprinter, they often walk through associations. You need to eager load everything the serializer touches at the query level.
Callbacks accessing associations. An after_save that touches post.author will raise if the post was loaded with strict loading. Either eager load before the callback fires or use strict_loading!(false) explicitly.
Delegation through associations. delegate :name, to: :author on a strict-loaded Post will raise when :name is called. The fix is the same: make sure author is eager loaded wherever this delegation is used.
View helpers and partials. A partial that calls post.comments.count somewhere buried three levels deep is a classic N+1 source. Strict loading makes these immediately visible.
Performance Impact of Strict Loading Itself
Strict loading adds negligible overhead. It sets a flag on the record object and checks that flag when an association proxy is accessed. The check is a simple boolean test. In benchmarks on Rails 8.0 with Ruby 3.3, enabling strict loading on 10,000 records added less than 1ms total overhead.
The performance you gain from catching missing eager loads far outweighs this cost. A single N+1 loop over 100 records can add 200-500ms to a request. Strict loading prevents that.
Strict Loading and has_many Counter Caches
One gotcha: if you use counter caches to avoid counting associated records, strict loading won’t interfere with them. Counter cache columns live on the parent model, so post.comments_count doesn’t trigger an association load. But post.comments.size might — size checks whether the association is loaded and falls back to a COUNT query if it isn’t, which strict loading will flag.
Use post.comments_count (the counter cache column) instead of post.comments.size under strict loading.
How It Fits with Other Performance Tools
Strict loading works well alongside other N+1 detection tools:
- Bullet gem: Bullet detects N+1 queries at runtime and suggests eager loads. Strict loading enforces them. Use Bullet in development for suggestions, strict loading in tests for enforcement.
- Prosopite: Similar to Bullet but with a different detection approach. Can run in production with minimal overhead.
db:explainand query logs: Good logging setup helps you spot the queries that strict loading flagged.
The combination of Bullet for discovery and strict loading for enforcement catches more N+1 issues than either tool alone.
FAQ
Does strict loading work with preload and eager_load or only includes?
All three work. includes, preload, and eager_load all satisfy strict loading requirements. The difference between them is how Rails constructs the SQL (separate queries vs. LEFT OUTER JOIN), but strict loading only cares whether the association data is already loaded when you access it.
Can I use strict loading in production?
You can, but most teams don’t. If a lazy load slips through in production, you get a 500 error instead of a slow page. The safer pattern is :raise in development/test and either :log or disabled in production. If you’re confident in your test coverage, :raise everywhere is an option.
How does strict loading interact with includes on nested associations?
Nested eager loading works as expected. If you includes(comments: :author), both post.comments and comment.author are satisfied. But if you only includes(:comments), accessing comment.author on a strict-loaded record will still raise.
What’s the difference between strict_loading and strict_loading!?
strict_loading is the query method that returns a new relation: Post.strict_loading.all. strict_loading! is the instance method that modifies a record in place: post.strict_loading! enables it, post.strict_loading!(false) disables it. The query method is generally preferred because it’s declarative and doesn’t mutate state.
Does strict loading affect belongs_to associations?
Yes. A belongs_to :author will raise on a strict-loaded record if the author isn’t eager loaded. This trips up some teams because belongs_to loads are usually fast (single row lookup by primary key), so they seem harmless. But 100 of them in a loop is still 100 queries. Strict loading correctly flags this.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation