Upgrading a Rails 6.1 app to Rails 7.0

Stefanni Brasil

At my previous client’s project, I got tasked with upgrading their large Rails app version from 6.1 to 7.0.8. It was my first time doing a Rails upgrade, and having benefited from many resources from the community, I wanted to give back by sharing the process.

I won’t cover any changes to the asset pipeline here. This app was already running on the latest webpacker version and it wasn’t included in this project.

Rails upgrade prep work

The first step to get ready for the Rails 7 upgrade was to go through the internal dependencies running on Rails 6. Why? Usually, when the team has other dependencies outside of the Rails monolith, the other dependencies get left behind running on old versions… until they can’t run on that version anymore.

This is not a future-proof approach and I see an upgrade as a job to be done to all internal frameworks the monolith relies on. Additionally, upgrading these smaller apps first served as a warm up for the monolith, which made me more at ease with the main app upgrade.

For this step, we recommend using the Appraisal gem to ensure your Ruby dependencies are backwards compatible with Rails 6.1 and with 7.0.8.

Once the internal dependencies were updated, it was time to move on to the monolith. There’s a lot of prep work before even getting to actually run the upgrade command. The first one is to be clear on what the project as a successful upgrade for the main app.

What does it mean for a Rails upgrade to be successful?

This is really important to have defined, otherwise you might put critical parts of the app at risk. Whatever you do, it’s always important to be aligned with the company’s business needs. Here are some examples:

  • Production deploys with no downtime;
  • Staging and QA environments deploy successfully;
  • All teams to test the Rails 7 branch in staging/QA;
  • Additional commands/steps for development are shared ahead of time with team before merging the changes.

Ruby Gems compatibility audit

Having upgraded the internal dependencies, it’s time to audit the external dependencies.

I changed the Rails version in the Gemfile to see what wasn’t compatible with Rails 7. You can find out what breaks by running the tests and booting the app.

For this specific client, we didn’t run into many problems because we used dependabot to keep the dependencies up to date. Keeping your dependencies up to date really makes upgrading Rails so much easier!

There were only two dependencies that weren’t compatible. One of them was a forked gem that we controlled, so it was easier to make it compatible with Rails 7. The other one wasn’t even used, so took the opportunity to remove it 🧹

Fix Rails 7 Deprecation warnings

After ensuring that our internal and external dependencies are compatible with Rails 7, the next step is to address the deprecation warnings being generated in Rails 6.

Rails ActiveSupport::Notifications

Even if the code has good test coverage, there are parts of the code that won’t be covered so you won’t even see the deprecation warnings.

That’s where subscribing to Rails ActiveSupport::Notifications - rails.deprecation hook shines. We added it and let it running in production.

I recommend subscribing to it when you start the upgrade work to find all the places you need to fix before the upgrade. Just send the events to whatever logging app you use. Here’s a snippet of the implementation:

# config/initializers/rails_deprecation_listener.rb

ActiveSupport::Notifications.subscribe("deprecation.rails") do |_name, _start, _finish, _id, payload|
  YourLogService.notify(
    message: ["RAILS 7 DEPRECATION WARNINGS"],
    deprecation_warning: warn,
    stack_trace: payload[:callstack],
    gem_name: payload[:gem_name],
    deprecation_horizon: payload[:deprecation_horizon]
  )
end

If you don’t have a custom logging strategy, tag them using Rails Logged tags to find them easily.

Oh, and if you haven’t yet, set production’s config.active_support.deprecation = :notify so your app generates the warnings.

Note: this hook will register all deprecations, so make sure to remove any internal deprecations from this event, since you don’t want to have them cluttering the deprecation warnings related to the upgrade.

Fix the deprecation warning messages from your test suite output

It’s time to address those warnings being generated when you run your tests. Because the test suite was super big, I ran them by folders. That also made it easier to fix them in smaller PRs.

As a dead code removal enthusiat myself, I took the opportunity to also remove pending and skipped tests that have been living for free in the codebase for years.

Here is a list of the deprecation warnings I encountered during this step.

Passing an Active Record object to quote directly is deprecated

DEPRECATION WARNING: Passing an Active Record object to `quote` directly is deprecated
and will be no longer quoted as id value in Rails 7.0.

🔗 Passing an Active Record object to quote/type_cast directly was deprecated in 2020.

In our case, the fix was to pluck the objects ids as opposed to sending the records themselves to be quoted.

Calling << to an ActiveModel::Errors message is deprecated

DEPRECATION WARNING: Calling `<<` to an ActiveModel::Errors message
array in order to add an error is deprecated. Please call
`ActiveModel::Errors#add` instead.

❌ Deprecated code:

record.errors[:snippet] << (options[:message] || "contains invalid data!!")

✅ Fixed code:

record.errors.add(:snippet, options[:message] || "contains invalid data!!")

ActiveModel::Errors#keys is deprecated

DEPRECATION WARNING: ActiveModel::Errors#keys is deprecated and will be removed in Rails 6.2.

To achieve the same use:

errors.attribute_names

❌ Deprecated code:

expect(instance.errors.keys).to include(:email_address)

✅ Fixed code:

expect(instance.errors.attribute_names).to include(:email_address)

Enumerating ActiveModel::Errors as a hash has been deprecated

DEPRECATION WARNING: Enumerating ActiveModel::Errors as a hash has been deprecated.
In Rails 6.1, `errors` is an array of Error objects,
therefore it should be accessed by a block with a single block
parameter like this:

person.errors.each do |error|
  attribute = error.attribute
  message = error.message
end

You are passing a block expecting two parameters,
so the old hash behavior is simulated. As this is deprecated,
this will result in an ArgumentError in Rails 7.0.

❌ Deprecated code:

user.errors.each do |attribute, _|
  expect(params.errors.key?(attribute)).to eq(true)
end

✅ Fixed code:

user.errors.each do |error|
  attribute = error.attribute
  expect(params.errors.key?(attribute)).to eq(true)
end

Deprecate rendering templates with “.” in the name

Change introduced in this PR.

Allowing templates with . introduces some ambiguity.
Is index.html.erb a template named index with format html,
or is it a template named index.html without a format? We (humans)
know it's probably the former, but if we asked ActionView to render
index.html we would currently get some combination of the two: a
Template with index.html as the name and virtual path, but with html
as the format. This deprecates having "." anywhere in the template's name, we should
reserve this character for specifying formats. I think in 99% of cases
this will be people specifying index.html instead of simply index.

We only had one place calling a ERB template with template_name.v1.json. Updating it to template_name_v1.json fixed the issue.

This was a deprecation we fixed by listening to the Rails ActiveSupport::Notifications hook. This was a legacy feature and although they were covered by request specs, they didn’t generate warnings for the views.

Active Record commit transaction on return, break and throw

From the PR that introduces this deprecation:

Historically only raised errors would trigger a rollback, but in Ruby 2.3, the timeout library started using throw to interupt execution which had the adverse effect of committing open transactions.

To solve this, in Active Record 6.1 the behavior was changed to instead rollback the transaction as it was safer than to potentially commit an incomplete transaction.

Using return, break or throw inside a transaction block was essentially deprecated from Rails 6.1 onwards.

However with the release of timeout 0.4.0, Timeout.timeout now raises an error again, and Active Record is able to return to its original, less surprising, behavior.

To fix this deprecation, you’ll want to make sure the code is well covered. There’s no straightforward fix here, so you will have to assess which keyword to use.

One hint: you might just need to update the order of the conditionals in the transaction to avoid returning early, for example.

Zeitwerk and Autoloading warnings

To get to Rails 7, you’ll need to address all the autoloading warnings since Zeitwerk has been integrated in this version:

DEPRECATION WARNING: Initialization autoloaded the constants Authorization, Authorization::BasePolicy, Authorization::AccountPolicy, and Authorization::Oauth.

Being able to do this is deprecated. Autoloading during initialization is going
to be an error condition in future versions of Rails.

Reloading does not reboot the application, and therefore code executed during
initialization does not run again. So, if you reload Authorization, for example,
the expected changes won't be reflected in that stale Module object.

These autoloaded constants have been unloaded.

In order to autoload safely at boot time, please wrap your code in a reloader
callback this way:

    Rails.application.reloader.to_prepare do
      # Autoload classes and modules needed at boot time here.
    end

That block runs when the application boots, and every time there is a reload.
For historical reasons, it may run twice, so it has to be idempotent.

Check the "Autoloading and Reloading Constants" guide to learn more about how
Rails autoloads and reloads.

These warnings happen because your app is expecting some classes to be available at boot time but aren’t.

To fix these warnings, you’ll need to either:

a - skip calling the code before initialization: not recommended because it usually means a significant refactor, which can derail the upgrade. Stay focused on the upgrade and document the follow up tasks for later. b - wrap the code in a reloader callback such as after_initialize, or to_prepare.

I recommend fixing these warnings one by one. In t took a few rounds of autoloading fixes to fully get compliant with zeitwerk.

Another step here is to review where the app still requires files that shouldn’t:

In a Rails application you use require exclusively to load code from lib
or from 3rd party like gem dependencies or the standard library.
Never load autoloadable application code with require. See why this was
a bad idea already in classic here.

require "nokogiri" # GOOD
require "net/http" # GOOD
require "user"     # BAD, DELETE THIS (assuming app/models/user.rb)
Please delete any require calls of that type.

It’s likely there are some code from Rails < 5 being required. Remove them, only using require calls for external libraries, and test the changes diligently.

If you’re interested in learning more nesting and loading modules and classes in Ruby, read Why you should nest modules in Ruby.

Once the deprecation warnings have been addressed, it’s time to actually start the upgrade.

Read Rails upgrade guides

Seriously. The Rails team does a great job at documenting everything in the Upgrade Guides.

The most notable Rails 7 config changes are:

  • Rails version is now included in the Active Record schema dump.
  • New ActiveSupport::Cache serialization format
  • Key generator digest class change requires a cookie rotator
  • Active Record Encryption algorithm changes

Go through it to familiarize yourself with the changes. I did this while fixing the deprecation warnings and testing smaller changes in staging.

Now that you know what to expect, it’s time to move on to updating the Rails version.

Run bundle update, but conservatively

Time to change the Rails version in the Gemfile and run bundle install.

However, on large apps with lots of dependencies, you’ll want to update the run a conservative update. In our case, we only want to update Rails immediate dependencies:

bundle update --conservative rails activesupport actionpack actionview activemodel activerecord actionmailer activejob actioncable activestorage actionmailbox actiontext railties

This command will make sure you only bump Rails dependencies versions. We want to make our lives easier and avoid upgrading other dependencies that are not required.

Run rails app:update

After all of this work, it’s time to actually upgrade the app 😅

Run the rails app:update command to overwrite the config files. It will generate the config/initializers/new_framework_defaults_7_0.rb file as well.

I overwrote all files, then reviewed them one by one one, line by line. This is optional, but I found it easier to use the config files from Rails 7 and add the ones specific for the application that are not the default values. It also kept the config files up to date with Rails default config values and comments (extracting those changes into smaller PRs as much as possible).

Knowing what config changes were applicable to our case, I focused on the breaking changes.

Prepare for Rails 7 breaking changes

Rails 7 new cache format version

In Rails 7, there is a new cache format config:

config.active_support.cache_format_version = 7.0

This was documented as a breaking change in the Upgrade notes. Thankfully, the guide was explicit about how to make this new config backwards compatible.

The way we did itwas to:

  • keep the current version defaults: config.load_defaults = 6.1;
  • uncomment the new default configuration values from the generated config/initializers/new_framework_defaults_7_0.rb file;
  • leave config.active_support.cache_format_version = 7.0 commented out.

Then, in our application.rb file:

config.load_defaults 6.1 # <---- still loading Rails 6 config values
config.active_support.cache_format_version = 7.0 # <---- Rails 7 is compatible with both 6.1 and 7.0 formats

This means the app will be running both 7.0 and 6.1 config values for a while. Follow up with removing config.active_support.cache_format_version = 7.0 and change the config defaults to config.load_defaults 7.0 to finalize the upgrade.

According to the upgrade guides:

The default digest class for the key generator is changing from SHA1 to SHA256. This has consequences in any encrypted message generated by Rails, including encrypted cookies. In order to be able to read messages using the old digest class it is necessary to register a rotator. Failing to do so may result in users having their sessions invalidated during the upgrade.

The guides provide an example on how to prepare your app and we basically copied and pasted it. We added a note to remove this file after 6 months.

Confirm CI is running on the new versions

Coordinate with the Platform/Infrastructure team to confirm CI/CD is running against the Rails version you’re upgrading to. Trust me, it’s easy to forget to update CI and have both deployment and tests builds running on old versions.

Run the app thoroughly locally as well

Staging and QA is great but we can’t forget the dev environment. In the Rails 7 branch, I went through the app and tested things again. This is how I caught one issue when loading some partials.

After a round of deprecation fixes, going through the app locally I noticed some partials were broken. They were being called like this:

<%= render "./accounts/footer" %>

Not sure why it breaks exactly (might be related to Zeitwerk) but removing the leading "./" fixed them:

<%= render "accounts/footer" %>

This is also the perfect moment to capture and share any changes for running the app locally with the team.

Canary deploy, if possible

Time to get things deployed.

Even with all this prep work, and testing in all environments, it’s possible something was missed. Running a canary deploy reduces the risk, so do it, if you can.

Rails 7 upgrade follow up tasks

After all of these steps, your Rails app is loading the default config values from Rails 6.1 and new config values from Rails 7. Leave that running for a while, and keep monitoring the app to see if you missed something.

Any backwards compatible code needed can be cleaned up? Any follow up tasks that you found during the upgrade? Make sure to work on them after the upgrade is deployed.

Update config.load_defaults 7.0

Without this change, your app is still running on Rails 6 config values. Make sure to update your config file to load Rails 7 values.

Embrace the Rails 7 way

Don’t forget to update whatever can be updated with what Rails 7 offers! After all, we went through all the trouble of upgrading the app to also benefit from the new stuff that’s been added, right?

I recommend watching this talk to see what’s available on Rails 7.

Address the “TimeWithZone#to_s(:db) is deprecated” warnings

DEPRECATION WARNING: TimeWithZone#to_s(:db) is deprecated. Please use TimeWithZone#to_fs(:db) instead.

You’ll probably find lots of deprecation warnings for these after changing the Rails version to 7 in the Gemfile. It might take lots of work to update all the calls to to_s. Depending on how your app relies on timezones, you’ll need to be more caution when fixing those deprecations.

Document the process for future Rails upgrades

Every app has its unique twerks. This isn’t the last upgrade, so any lessons, gotchas, unexpected issues, will help out the next ones. Create an upgrade guide, if there isn’t one already.

Clean up your config files

This project is a Rails app that was created on Rails version 3. Rails config changes a lot and keeping the config files updated gives them a fresh look and makes the next upgrades easier. We took this opportunity to remove configuration from the config files that had become default at some point.

Rails upgrade resources

What Rails version is your app running?

This was my first and I learned a lot. I am curious to know: have you ever done a Rails upgrade? How was it for you?

If your team needs any help with upgrading your Rails app, send us a message so we can work together.

In the meantime, keep your dependencies updated :)