Ruby on Rails Upgrade Guide
Learn how to keep your Rails applications modern and fast.
In 1913, the Ford Motor Company introduced the first moving assembly line for cars. Before this, assembling a Model T was a stationary event that took over twelve hours of effort. The assembly line changed the paradigm: instead of one massive, stationary effort, the work became a continuous, moving process.
We are not building cars, but the same principle applies to upgrading a Ruby on Rails application. Treating an upgrade as a massive, once-every-five-years event often leads to frustration and systemic failure. Instead, upgrading Rails should be a continuous process.
The Continuous Upgrade Philosophy
Strictly speaking, the most effective way to manage upgrades is to stay close to the main branch of rails/rails, or at least stay up to date with point releases. When you do encounter a major version upgrade, having comprehensive test coverage is your greatest asset. We will explore a systematic approach to bridging the gap between major versions.
Preparing for the Upgrade
Before we change any code, we must understand our current state.
Resolving Deprecations
Always fix deprecation warnings on your current Rails version before attempting to jump to the next one. The Rails core team typically introduces deprecation warnings in minor versions and removes the underlying code in the subsequent major release.
Auditing Dependencies
We can audit our dependencies using Bundler:
$ bundle outdated
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Outdated gems included in the bundle:
* devise (newest 4.9.3, installed 4.8.1, requested ~> 4.8)
* nokogiri (newest 1.15.4, installed 1.13.10)
* pg (newest 1.5.4, installed 1.4.6) This command will show which non-Rails gems have newer versions available. Often, gem maintainers release new versions specifically to support upcoming Rails releases. You also may notice that Bundler shows the newly available versions compared to the currently installed and requested versions.
Execution Strategies
There are two major approaches to working through a Rails upgrade, each with distinct trade-offs. The choice depends on your application's size, team structure, and tolerance for complexity.
The first approach is a branch-based upgrade. Create a feature branch, update dependencies, fix tests, and merge when complete. This approach requires minimal additional tooling and is straightforward to implement. It works best for small applications or teams with limited bandwidth, as it minimizes setup overhead. However, the branch-based approach isolates the upgrade work from ongoing development, potentially blocking feature merges until the upgrade is finalized.
The second approach is dual-booting, where your application runs on both the old and new Rails versions simultaneously. Tools like the next_rails gem facilitate this by managing separate Gemfile.lock and Gemfile.next.lock files. Dual-booting allows the team to continue shipping features while incrementally validating upgrade compatibility. The trade-off is increased setup complexity and the ongoing maintenance of two dependency sets, which can slow local development due to larger install times and potential synchronization issues.
When possible, start with the simpler branch-based approach unless the benefits of continuous development outweigh the maintenance overhead. Some teams use a hybrid strategy: perform the bulk of the upgrade in a branch while periodically testing against the new version in a dual-boot environment to catch issues early.
Executing the Upgrade
Updating the Gemfile
When we are ready to proceed, we update our Gemfile to point to the new version. It is crucial to jump one minor version at a time - for example, from 6.0 to 6.1, and then from 6.1 to 7.0. Do not skip minor versions.
One may wonder: if we have good test coverage, why do we need to upgrade one minor version at a time?
Technically, you don't.
... but if you don't, you may end up with strange, off-the-beaten-path problems. If you're not sure you're ready to handle that, don't go there. Remember the parable of the tortoise and the hare; you might save time by cutting corners, but you may also waste it.
As mentioned earlier, deprecation warnings are typically introduced in minor versions and removed in the subsequent major version. If we skip minor versions, we lose the opportunity to see and address those deprecations before they become raised exceptions.
gem 'rails', '~> 7.0.0' After updating the file, we can install the new version:
$ bundle update rails
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Fetching rails 7.0.8
Installing rails 7.0.8
...snip...
Bundle updated! I’ve abbreviated the above output for the sake of brevity, but the command will download and install the new Rails version and any updated dependencies.
Applying Framework Defaults
As a safety reminder, the rails app:update command modifies files in your repository; therefore, before you use this command, it is wise to ensure the latest known good version of your codebase is committed to source control.
Once Bundler has successfully resolved the new dependencies, we must update the framework’s configuration files. Rails provides an interactive task for this purpose:
$ rails app:update
exist config
conflict config/application.rb
Overwrite /Users/name/project/config/application.rb? (enter "h" for help) [Ynaqdhm] d This task modifies your configuration files to match the new defaults. When prompted with a conflict, you can enter d to view the diff output. Carefully review all diff outputs. When in doubt, it is generally best to prefer the new Rails defaults and migrate your custom settings over them.
Testing and Remediation
Finally, we run our test suite against the new version and address any breaking changes. This is where the dual-boot strategy demonstrates its value - we can run tests against both our old lockfile and the new lockfile in our continuous integration environment:
$ bundle exec rspec
...snip...
0 failures
$ DEPENDENCIES_NEXT=1 bundle exec rspec
...snip...
0 failures Running tests against both environments ensures we do not introduce regressions for our production environment while finalizing the upgrade.