Null references are sometimes called programming’s “billion dollar mistake”, due to the countless hours spent hunting down and fixing null-related bugs. I like to think that in an alternate universe, null wasn’t introduced and everyone is much happier.

The N+1 query problem

If Rails has a billion dollar mistake, it’s ActiveRecord’s lazy loading of associations. When this happens in a loop, it’s called the “N+1 query problem”, and it’s the #1 performance killer in any and every Rails app.

Bullet

The Rails ecosystem has developed quite a bit of tooling to help with this, perhaps most famously the bullet gem. Bullet warns you when it detects N+1’s running, and it’s a helpful tool. But it’s only intended for development and testing.

The idea is that by the time your code gets to production, all the N+1 queries will have been found and fixed. Unfortunately, for large, real-world apps, that often isn’t the case. Conditionals, optional fields, and feature flags all conspire to hide N+1 queries from your test suite and manual testing. There is simply no substitute for production.

By all means keep using Bullet. But make no mistake: your app is still bedeviled by N+1 queries.

strict_loading

The Rails maintainers must agree with me, because strict_loading was introduced in Rails 6.1. After some initial fanfare, I’ve heard almost nothing about it and talked to nearly no one who’s used it. Having toyed with it, and having developed some prior art (Occams Record), I can understand why.

The core idea is good: native support for helping with lazy loading issues without breaking existing apps. Sadly, the implementation isn’t great. I haven’t researched the history, but it has the feel of design-by-committee. There’s a dizzying number of options, some of them global, and very little guidance on how to use it all. (To be fair, it’s a difficult problem to address while keeping Rails feeling like Rails.)

The rest of this post will focus on the various ways you can use strict_loading, finishing with a recommended path forward for large, existing apps.

Individual queries

q = Widget.
  where(...).
  preload(:parts).
  strict_loading

# this will fail b/c :category wasn't preloaded
q.find_each do |widget|
  puts widget.category.name
  => ActiveRecord::StrictLoadingViolationError
end

Individual records

widget = Widget.find(42)
widget.strict_loading!

puts widget.category.name
=> ActiveRecord::StrictLoadingViolationError

# you can disable it
widget.strict_loading!(false)
puts widget.category.name

# or you can enable it only for N+1 queries
widget.strict_loading!(mode: :n_plus_one_only)

# this works
puts widget.category.name

# even this works
puts widget.parts.to_a

# but this doesn't
puts widgets.parts.map { |p| p.category.name }
=> ActiveRecord::StrictLoadingViolationError

Entire associations

These associations will always need to be preloaded.

class Widget < ApplicationRecord
  belongs_to :category, strict_loading: true
  has_many :parts, strict_loading: true
end

Entire models

Any and all .category calls will need to be preloaded.

class Category < ApplicationRecord
  self.strict_loading_by_default = true
end

Entire applications

If you’re very brave, you can opt your entire application into strict loading. (Oddly, there doesn’t seem to be an equivalent of :n_plus_one_only here. I can’t imagine using this.)

# config/application.rb
config.active_record.strict_loading_by_default = true

To raise or log?

If all these ActiveRecord::StrictLoadingViolationError exceptions have you afraid, you’re not alone. But don’t worry, there’s a way to switch them off while still logging the violation:

# config/application.rb
config.active_record.action_on_strict_loading_violation = :log

You’ll notice that’s a global option, which makes it a pretty big decision for your app. A call-site option (e.g. an argument passed to strict_loading) would have been preferable IMO. Regardless, any lazy loading will log this:

`Widget` is marked for strict_loading. The Category association
named `:category` cannot be lazily loaded.

Supposedly this message is followed by a single-line backtrace, but I haven’t always been able to find it.

Things it can’t catch

So unlike Bullet, this will solve everything, right? Not necessary. Rails being Ruby, you can still do almost anything you want anywhere you want. Does your ActiveRecord model have a method that runs queries like this?

  def category_name
    Category.where(id: category_id).first&.name
  end

Or like this?

  def foo
    Widget.find_by_sql(...)
  end

Those won’t be caught. The best answer is “don’t do that”. I’m not the first to say this, but it’s worth saying: ActiveRecord instance methods really aren’t the place to be touching the database unless you have the simplest of possible apps.

So how should I use all this?

:log mode is the only reasonable option in my view. Monitor your staging and production logs, cleaning up violations as they occur.

# config/application.rb
config.active_record.action_on_strict_loading_violation = :log

Greenfield apps might get away with turning strict loading on application wide, but most of us will need to opt certain areas in. Start with the queries driving your index pages/APIs, followed by reports and any other areas of your app where you detect slowness.

q = Widget.
  where(...).
  order(...).
  preload(...).
  strict_loading

And if you don’t like the default, rather anemic, log message, there’s hope. 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_violation.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)
}

Final thoughts

There’s no fool-proof way of freeing ourselves from this lazy loading morass. One could even argue that without lazy loading, Rails would have been harder to learn, and therefore less successful. We’ll never know. But given where we are, I think the above recommendations are a pretty good start.