Catching N+1 Queries Early with Rails strict_loading
Learn how Rails 6.1's strict_loading mode turns lazy association loads into explicit errors, helping you spot N+1 problems before they hit production.
26 Nov 2025, 13:21 UTC

The Problem: Silent N+1 Queries
Rendering a collection and accessing an association inside the loop is a classic Rails pitfall. Each iteration issues a separate SQL query, but the application still works — until the data grows and the page slows down. The bug is invisible in development because the log just shows many tiny queries.
How strict_loading Works
Since Rails 6.1, Active Record can raise ActiveRecord::StrictLoadingViolationError the moment an association is lazily loaded instead of preloaded. You can enable it in three ways:
- Per‑model default:
self.strict_loading_by_default = trueinside the model class. - Per‑query:
Post.strict_loading.allorPost.where(published: true).strict_loading. - Global environment config:
config.active_record.strict_loading_by_default = trueinconfig/environments/development.rb(or test/production).
The per‑query opt‑in is the pragmatic migration path for existing codebases: mark hot or suspicious queries strict first, fix the violations, then widen the scope.
Worked Example: Posts with Comments
Before — the N+1
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
# app/views/posts/index.html.erb
<% @posts.each do |post| %>
<%= post.title %>
Comments: <%= post.comments.count %>
<% end %>
With strict loading enabled on the query, the first post.comments access raises:
ActiveRecord::StrictLoadingViolationError:
Post is marked as strict_loading and comments association was lazily loaded.
Use `includes(:comments)` or `preload(:comments)` to load the association eagerly.
After — one‑line fix
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.strict_loading.includes(:comments).all
end
end
The log now shows a single SELECT ... FROM comments WHERE comments.post_id IN (...) instead of one query per post.
Trade‑offs and Limits
- Coverage depends on exercised paths — strict loading only surfaces N+1s that your tests or local browsing actually trigger. Untested branches can still ship lazy loads to production.
- includes vs. joins —
includes(:comments)may generate a separate query or a LEFT OUTER JOIN depending on conditions. Verify the generated SQL (Post.strict_loading.includes(:comments).to_sql) rather than assuming. - Non‑association N+1s — per‑record calculations like
post.comments.countwithout a counter cache still hit the database once per record. Those need explicit aggregates or counter caches. - Version sensitivity — the configuration key
config.active_record.strict_loading_by_defaultexists from Rails 6.1 onward. Confirm the exact option name in your version’s guide before enabling globally.
Next Steps: Roll Out Safely
- Enable in test: add
config.active_record.strict_loading_by_default = truetoconfig/environments/test.rband run the full suite. Every violation becomes a failing test you can fix withincludes/preload/eager_load. - Enable in development: once the test suite passes, flip the same flag in
development.rb. Exercise the app manually; any new errors point to missing preloads. - Gradual per‑query adoption: for large legacy codebases, start with
Model.strict_loadingon the most traffic‑heavy controllers. Fix each violation, then expand. - Production guard: keep the global flag off in production until you have confidence. You can still use per‑query strict loading in production for critical endpoints as a safety net.
After each change, verify the fix by checking the SQL log: a single preloading query should replace the previous cascade of lazy loads. This incremental approach lets you reap the performance benefits of eager loading without a risky big‑bang deployment.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.