The Rails Upgrade Survival Guide: How to Cross Multiple Versions Without Losing Your Mind
The Upgrade Debt Trap
Every Rails version you skip makes the next jump harder. It’s compounding technical debt — and unlike financial debt, there’s no minimum payment option. You either pay it down or it grows.
Teams that fall behind on Rails upgrades don’t usually do it on purpose. One version gets skipped because “we’re too busy shipping features.” Then another. Then the Ruby version falls behind too. Suddenly you’re on Rails 5.2, running Ruby 2.5 on an EOL runtime with no security patches, and half your gems haven’t been maintained since 2020.
This isn’t hypothetical. I see it constantly.
The business risk is real: EOL frameworks mean unpatched vulnerabilities. Your insurance carrier, your compliance team, and your customers’ security questionnaires all care about this. Beyond security, your gem ecosystem drifts out of compatibility. New hires don’t want to work on ancient stacks. Deployment tooling moves on without you.
The good news: there’s a proven path through this. The only sane approach is stepping through each significant version, one at a time. No skipping, no shortcuts, no heroics. Here’s the playbook.
Phase 1: Assess Before You Touch Anything
Before you change a single line of code, you need to understand what you’re dealing with. Skipping this phase is how two-week projects become two-month ones.
Know Your Starting Point
Document your current Rails version, Ruby version, and how many version jumps you’re facing. If you’re on Rails 5.2 targeting 8.0, that’s five significant version boundaries: 5.2 → 6.0 → 6.1 → 7.0 → 7.1 → 8.0. Each one is its own mini-project.
Audit Your Gemfile
Run bundle outdated and actually read the output. What you’re looking for:
- Abandoned gems — no commits in two or more years, no Rails 7/8 support. Paperclip is the classic example: dead since 2018, replaced by ActiveStorage.
- Renamed or merged gems — some gems get absorbed into Rails itself. Others get forked under new names.
- Version-locked gems — anything pinned to an exact version is a potential blocker.
Check the changelog and issue tracker for every gem that hasn’t been updated recently. If a gem doesn’t support the Rails version you’re targeting, you need a replacement plan before you start.
Evaluate Test Coverage
I’ll be blunt: if your test suite doesn’t exist or doesn’t pass, stop here. Fix that first. Upgrading without tests is flying blind — you’ll have no way to know what broke until your users tell you.
You don’t need 100% coverage. You need passing tests that cover your critical business logic, your API endpoints, and your most-used workflows. If you’re below 70% meaningful coverage, invest in tests before you invest in the upgrade.
Tip: Run your test suite with deprecation warnings enabled. Those warnings are a preview of what breaks in the next version.
Map Your Ruby Version Path
Each Rails version has minimum Ruby requirements. Plot the full path:
- Rails 5.2 → Ruby 2.5+
- Rails 6.0 → Ruby 2.5+
- Rails 7.0 → Ruby 2.7+
- Rails 7.1 → Ruby 3.0+
- Rails 8.0 → Ruby 3.2+
If you’re on Ruby 2.5 targeting Rails 8.0, you’ll need to upgrade Ruby along the way. Plan where those Ruby jumps happen.
Check Your Deployment Pipeline
Can your CI servers, hosting environment, and Docker base images handle the Ruby version you’ll need at the end? If you’re deploying to a platform that doesn’t support Ruby 3.2 yet, that’s a blocking constraint you need to know about now, not three weeks in.
This assessment phase is where most teams underestimate the work. A few hours of experienced eyes here saves weeks of wrong turns later.
Phase 2: Prepare Your Codebase
You’ve assessed the situation. Now prepare the codebase for surgery. This phase is entirely about reducing risk before the actual upgrade begins.
Get Green First
Your test suite must pass on the current Rails version before you change anything. If tests are failing now, you won’t know which failures are yours and which are caused by the upgrade. No exceptions.
Remove Dead Gems
Every gem in your Gemfile is a potential blocker during the upgrade. Audit aggressively. That analytics gem nobody uses? The admin dashboard gem you replaced with a custom solution two years ago? Remove them. Every gem you remove is one less compatibility conflict to resolve.
Replace Abandoned Gems Proactively
Don’t wait to discover that Paperclip doesn’t support Rails 6. Switch to ActiveStorage now, on your current Rails version, while everything else is stable. Same for any gem that you know won’t survive the version jumps. One change at a time.
Pin and Lock
Run bundle lock to capture your current known-good dependency state. If the upgrade goes sideways, you need a clean rollback point. Tag the commit. You’ll be glad you did.
Create a Long-Running Branch
Multi-version upgrades aren’t a single pull request. Create a dedicated branch. You’ll be living on it for a while, merging main into it regularly to stay current.
Fix Deprecation Warnings Now
Deprecation warnings on your current Rails version are your roadmap for the next jump. Fix them now, while the current version still works. This converts unknown future failures into known, fixable issues.
Tip: Run
RAILS_ENV=test bin/rails test 2>&1 | grep "DEPRECATION"to collect all warnings in one pass.
This preparation phase is where I see teams stall the most. They want to jump straight to changing the Rails version. Every hour spent here saves a day of debugging later.
Phase 3: Execute — One Significant Version at a Time
This is the core of the upgrade. The rule is simple: one significant version per pass. 5.2 → 6.0 → 6.1 → 7.0 → 7.1 → 8.0. Never skip versions. Each boundary has its own set of changes, deprecations, and gem compatibility shifts. Skipping one means dealing with two sets of breaking changes simultaneously, which is exponentially harder to debug.
The Upgrade Loop
For each version jump, follow this sequence:
- Update your Gemfile to the next Rails version.
- Run
bundle update railsand resolve gem conflicts one by one. Don’t try to update everything at once. - Run
rails app:updateand review each generated diff carefully. Don’t blindly accept changes — understand what each config change does. - Run the test suite. Fix what breaks.
- Boot the app and smoke test critical paths manually. Some things only surface at runtime.
- Fix remaining deprecation warnings. These are your roadmap for the next jump.
- Commit. Then repeat for the next version.
This loop is mechanical. That’s the point. Discipline beats cleverness here.
Version Boundary Gotchas
Each boundary has its signature pain points:
5.2 → 6.0: The autoloader switch from classic to Zeitwerk is the big one. If your app has non-standard file naming or directory structures, expect to spend time here. ActionCable internals change, and the new framework defaults system means you need to understand config.load_defaults.
6.0 → 6.1: Zeitwerk becomes the only autoloader — classic mode is gone. config.load_defaults 6.1 changes several defaults. ActiveStorage gets a new service URL scheme. This is usually the smoothest major boundary, but don’t skip it.
6.1 → 7.0: Ruby 2.7+ becomes mandatory. rails-ujs is deprecated in favor of Turbo — it still works, but it’s no longer the default for new apps. If you were on Sprockets, you now have the option to move to Propshaft (or stay on Sprockets — it still works). Encrypted credentials handling changes.
7.0 → 7.1: Ruby 3.0+ required. Async queries arrive, Dockerfile generation is built in, and config.load_defaults 7.1 changes default behaviors around enums and ActiveRecord. Smoother than the 6.x→7.0 jump, but test your query patterns.
7.x → 8.0: Ruby 3.2+ required. Kamal becomes the default deployment tool, Solid Queue and Solid Cache replace traditional Redis-backed jobs and caching by default, and Propshaft replaces Sprockets as the default asset pipeline. The new authentication generator is available but optional.
Gem Conflicts Are the Real Time Sink
Expect two to five gems per version jump that need updating, replacing, or removing. This is where the hours go. A single obstinate gem with a narrow Rails version constraint can block your entire upgrade until you find a compatible version, fork it, or replace it.
Don’t combine Rails upgrades with Ruby upgrades in the same pass unless a Rails version forces your hand. Change one thing at a time. When something breaks, you want to know exactly which change caused it.
Tip: Keep a running list of every gem you had to change and why. Future upgrades get easier when you have a history.
This phase is mechanical but unforgiving. One wrong gem version cascades into hours of debugging. Having someone who’s navigated these specific version boundaries before is the difference between a two-week upgrade and a two-month one.
Phase 4: Validate — Prove It Works
You’ve stepped through every version and the app boots. You’re not done. The most dangerous bugs in a Rails upgrade are the silent ones.
Run the Full Suite — Then Look at What’s Not Tested
A passing test suite is necessary but not sufficient. Look at your coverage report. The untested code paths are your real risk areas. Pay special attention to background jobs, mailers, webhook handlers, and anything involving serialization.
Check for Silent Breakage
Some Rails changes don’t raise errors — they change behavior. ActiveRecord query semantics shift between versions. Default serialization formats change. Cookie and session handling evolves. These won’t fail your tests. They’ll fail your users.
Diff the behavior of critical endpoints before and after. Compare database queries. Check that JSON responses haven’t subtly changed shape.
Performance Baseline
Run the same representative requests against the old and new versions. Rails upgrades generally improve performance, but gem changes and new defaults can introduce regressions. Know before your users do.
Review config.load_defaults
Don’t just set config.load_defaults to the latest version and move on. Understand what each setting changes. Step up the defaults version incrementally if needed — you can set it to an earlier version and override individual settings as you validate them.
Stage It
Deploy to staging first. Obvious advice, routinely skipped under deadline pressure. Run it in staging for at least a few days under realistic traffic before production.
This is where teams rush. The upgrade is “done,” pressure mounts to ship, and subtle production bugs slip through. An experienced set of eyes knows exactly where Rails buries the behavioral changes.
Phase 5: Harden — Stay Current Going Forward
The most expensive Rails upgrade is the one you could have avoided by keeping current. Now that you’ve done the hard work, don’t end up back here in three years.
Upgrade with every minor release. Rails 7.1 → 7.2 is a few hours of work at most. Rails 5.2 → 8.0 is a multi-week project. Do the small ones routinely so you never face the big one again.
Keep Ruby current too. One version behind is maintenance. Three versions behind is a project. Ruby releases annually — upgrade within a few months of each release.
Run bundle outdated monthly. Don’t let gem drift accumulate. When a major gem releases a new version, update it promptly rather than letting it sit.
Fix deprecation warnings immediately. Every deprecation warning is a future breaking change announcing itself in advance. Fix them as they appear, not in bulk before the next upgrade.
Budget for maintenance. If your engineering budget doesn’t include time for keeping dependencies current, you’re building upgrade debt on purpose. Set aside time every month — or every quarter at minimum — for dependency maintenance.
Most of my upgrade engagements end with this conversation: how to make sure you never fall this far behind again. Sometimes that means a quarterly maintenance retainer. Sometimes it means coaching your team to own it. Either way, the cheapest upgrade is the one you do on time.
Note: Staring down a multi-version Rails upgrade and not sure where to start? Let’s talk through it — I’ve guided teams through dozens of these and I’m happy to assess your situation.