Retiring Active Record Associations the Rails 8.1 Way
A familiar scene from fractional work: you've been brought in on a Rails app that's been around for six or seven years. The team is small, the test suite is patchy in the older corners, and somewhere in app/models/user.rb there's a has_many :legacy_subscriptions that nobody quite remembers the purpose of. Maybe it gets used. Maybe it doesn't. The git blame goes back to a developer who left in 2021.
You'd like to delete it. You can't, because you don't actually know.
Rails 8.1 ships a small feature aimed exactly at this situation: associations can now be marked as deprecated: true, and Active Record will report many of the ways that association is used: direct calls, executed queries, nested attributes, and certain side effects. It's not a flashy headline feature, but it's the kind of thing you reach for repeatedly once you know it's there. Worth a proper look.
What it actually does
The basic shape is just what you'd hope:
class Author < ApplicationRecord
has_many :posts, deprecated: true
end
Once that's in place, any usage of the association, direct calls, preloading, joins, nested attributes, and association side effects such as dependent: and touch: is reported. By default Rails logs a warning with the source location of the call. In :warn and :notify modes the association still works exactly as before; :raise mode is the exception, and intentionally changes behaviour by raising.
The reporting is more thorough than I expected the first time I tried it. All of the following trigger a warning:
author.posts # direct access
author.posts = [post] # assignment
Author.preload(:posts).load # preloading (executes)
Author.includes(:posts).load # eager loading (executes)
Author.joins(:posts).where(posts: { ... }).load # executed joins query
Author.eager_load(:posts).where(posts: { ... }).load # executed eager_load query
author.update(posts_attributes: ...) # nested attributes
author.save # may warn when the association has touch: side effects
author.destroy # may warn when the association has dependent: cascades
Those last two are the most interesting. They're the kind of indirect use that's easy to miss when you're searching for author.posts in your editor. Rails wires the deprecation check into the association reflection itself, so it catches the usage regardless of the entry point.
This feature was originally written at Gusto as an internal monkey patch and upstreamed by Xavier Noria. That origin shows in the design, it's clearly built by people who actually had to retire associations in a real codebase, not designed in the abstract.
The three modes
There are three reporting modes, and they're worth knowing well because the right one depends entirely on where you're sitting in the deprecation lifecycle.
:warn (default): logs a deprecation warning via the Active Record logger. Good for early-stage discovery. It tells you what's there without breaking anything.
:raise: raises ActiveRecord::DeprecatedAssociationError. This is the one I reach for in test environments. If you've decided an association is genuinely on its way out, switching to :raise in tests means any code path that touches it fails the suite immediately, not buried in a log file somewhere.
:notify: emits an ActiveSupport::Notifications event named deprecated_association.active_record. The payload includes :reflection, :location, and :message, plus :backtrace when backtraces are enabled. This is the production friendly mode: pipe it into Sentry, Honeybadger, New Relic, or whatever you already have.
You can configure all of this globally or per environment:
# config/application.rb
config.active_record.deprecated_associations_options = {
mode: :warn,
backtrace: false
}
# config/environments/test.rb
config.active_record.deprecated_associations_options = {
mode: :raise,
backtrace: true
}
# config/environments/production.rb
config.active_record.deprecated_associations_options = {
mode: :notify,
backtrace: true
}
Backtraces are disabled by default. The source location of the call is always reported regardless, but if the usage is happening through shared code or a helper, the cleaned backtrace is what you actually want. I'd enable it almost everywhere except possibly the highest traffic production paths, where the noise might add up.
A practical workflow
Here's roughly how I work through it on a client codebase. Adapt to context, but the broad shape tends to hold.
1. Mark the association
Add deprecated: true to the association declaration. Don't change anything else yet — you want a clean signal of where the usage is coming from before you start refactoring.
class User < ApplicationRecord
has_many :legacy_subscriptions, deprecated: true
end
2. Run the test suite with :raise
In config/environments/test.rb, set the mode to :raise. Run the suite. Every failing test points to a code path that touches the association.
This is genuinely useful even on its own. If the suite passes, you've learned something: either the association truly is unused, or your test coverage doesn't exercise it. Both pieces of information are important, and they're easy to confuse before you do this step.
Fix the failures one by one. Most are usually quick: a preload(:legacy_subscriptions) that can be dropped, a callback that no longer needs the association, an admin view that's surfacing something nobody looks at. Some will be genuinely thorny and need a follow up ticket.
3. Deploy with :notify to staging or production
Once the test suite is clean, deploy with :notify mode pointed at your error tracker. Subscribe to the event:
# config/initializers/deprecated_association_reporting.rb
ActiveSupport::Notifications.subscribe("deprecated_association.active_record") do |event|
Sentry.capture_message(
"Deprecated association used: #{event.payload[:message]}",
extra: event.payload
)
end
Now wait. How long depends on the app, for a B2C app with daily active users, a week of normal traffic is usually enough. For a B2B app with monthly batch jobs, end of quarter reports, or seasonal behaviour, you want to wait for at least one full cycle of whatever it is.
This is the step that catches the things tests won't: the rake task that runs on the first of the month, the export job somebody triggers from the admin panel twice a year, the legacy webhook handler that wakes up when a specific partner posts.
4. Remove the association
When the notifications stop arriving, delete the association declaration, then remove any now unused foreign keys, join tables, or supporting schema safely. At this point you're doing the cleanup with actual evidence rather than guesswork, which is the whole point.
A few things worth knowing
A handful of practical observations from using this in anger:
The :raise mode in development is a useful nudge. Even if you keep production on :notify, raising in development catches the case where you accidentally introduce a new use of a deprecated association in a fresh feature. Better than discovering it three weeks later in the staging logs.
Don't deprecate too many associations at once. I've found two or three at a time is a comfortable working batch. Mark too many and the noise ratio in your error tracker drops, and the temptation to mute the alerts gets stronger than it should be.
Mocked associations in tests can lie to you. If your tests stub out User#legacy_subscriptions rather than calling the real association, the deprecation reporter won't fire. Worth a quick grep for stubs of any association you're deprecating before you trust a green test run.
Background jobs are the usual blind spot. If you have Sidekiq, Solid Queue, or similar running scheduled jobs, make sure the worker processes are actually reporting deprecations to the same place as the web tier. It's easy to instrument the web side and forget the workers.
When it doesn't help
A couple of cases where this feature is the wrong tool:
- You're trying to deprecate a column, not an association. Different problem. The association deprecation only catches usage that goes through Active Record's association machinery. Direct attribute access on the model isn't covered.
- You're on Rails 8.0 or earlier. The feature is 8.1+ only. If you're still on 8.0, my last post on the security only support transition is probably the more pressing reading first.
- The association is fine and you just don't like the name. Use
alias_attributeor rename it cleanly.deprecated: trueis for retirement, not renaming.
The real win
Before Rails 8.1, the answer to "can we delete this association?" was usually "let me grep the codebase, run the tests, deploy it, and hope." Now it's "let me deprecate it, watch the logs for two weeks, and then delete it." The second version is much easier to justify to a non technical stakeholder, and it's much harder to get wrong.
Small feature, real impact. Worth knowing.
If you're working through a Rails codebase that's accumulated more associations than it should have, or you'd like a hand thinking through a careful retirement plan for a few of them, [drop me a line](mailto:wm@wagnermatos.co.uk). I do this kind of work on a fractional and contract basis.