A Rails upgrade from 5.2 or 6.x to 8.1 has several finish lines. The application must boot, preserve its behavior, read existing data and survive a mixed deployment with the previous release. A successful bundle install establishes none of those by itself.
The useful unit of work is one minor-version hop with evidence attached. Below is the route, a tested 7.0-to-7.1 example and the decisions that deserve separate changes. The examples are synthetic applications prepared for this article, not accounts of client upgrades. Versions and source claims were checked on 13 September 2026.
Plan the Rails upgrade before changing the Gemfile
Start with the latest patch of the application’s current minor, then move through each minor release. Deprecations in that patched baseline identify work the next release expects you to have completed. The official upgrading guide describes this sequence. Unsupported intermediate releases are rehearsal checkpoints; a green checkpoint is not a recommendation to expose another unsupported production deployment.
Keep the existing config.load_defaults value while changing the framework. Review the generated defaults file one setting at a time, with a test and a commit for each adopted change. Cookies, caches and serialized columns need reader/writer compatibility checks and separate rollout decisions. Earlier 4.2 and 5.0 applications predate load_defaults; inspect their individual initializers instead.
Build a gem verdict list before cutting the upgrade branch. Keep both framework bundles running in CI so small compatibility fixes can merge while ordinary development continues. Change Ruby between Rails hops, using an actually resolved and tested overlap. Pin the existing asset pipeline during the hop. Finish the Zeitwerk conversion on 6.x, while the transition is still available, before attempting 7.0.
These choices make a failure attributable. They also leave room to stop at a failed check without quietly accepting a different transaction, authorization or serialization behavior.
The seven rungs
The labels match Allerin’s upgrade ladder. Grouped labels still contain separate minor upgrades. Ruby values below are declared minimums from the tagged gemspecs, not a promise that every later Ruby runs an old Rails release. The download contains all eleven patch checkpoints and five component checks per checkpoint.
| Hop | Ruby minimum at destination | Work to verify | Separate decision |
|---|---|---|---|
| Rails 4.2 to 5.0, 5.1, 5.2 | 2.2.2 throughout 5.x | Model callbacks, parameters, historical migrations | Credentials and attachment migration |
| Rails 5.2 to 6.0 to 6.1 | 2.5.0 throughout 6.x | Zeitwerk, query semantics, removed APIs | JavaScript tooling and cookie formats |
| Rails 6.1 to 7.0 | 2.7.0 | Classic removal, explicit asset dependencies | Ruby keywords, cookies, cache, variants |
| Rails 7.0 to 7.1 | 2.7.0 | Column coders, exceptions, callback order | Message and cache formats |
| Rails 7.1 to 7.2 | 3.1.0 | Secrets removal, job tests, enum calls | Runtime and queue policy |
| Rails 7.2 to 8.0 | 3.2.0 | Removed deprecations and dependency constraints | Queue, cache and asset replacement |
| Rails 8.0 to 8.1 | 3.2.0 | Schema dump and affected finder ordering | Business ordering and unrelated rewrites |
Rails 4.2 to 5.0, 5.1, 5.2
Rails 5.0 in 2016 introduced generated ApplicationRecord bases and required belongs_to defaults. Review callbacks returning false, use throw(:abort) for intentional halts, and inspect code treating controller parameters as a Hash. Migrate away from protected_attributes. The removal of render :text and :nothing belongs to 5.1, not 5.0.
5.1 in 2017 added system tests and changed generated primary keys to bigint. 5.2 in 2018 introduced credentials and Active Storage; installing it does not migrate existing secrets or attachments. Review use_authenticated_cookie_encryption, including embedded expiry, before changing cookies read by older processes.
Rails 5.2 to 6.0 to 6.1
6.0 in 2019 brought Zeitwerk, host authorization and parallel testing. Webpacker was a generated-app choice, not an instruction to replace an existing JavaScript build. The use_cookies_with_metadata default adds purpose and expiry metadata, requiring its own compatibility decision.
On 6.1 in 2020, inspect multi-attribute where.not queries for the change to NAND semantics, removed update_attributes calls and symbol access in config_for. Review has_many_inversing, cookies_same_site_protection and Active Storage’s required service_name migration against actual behavior.
Rails 6.1 to 7.0
7.0 in 2021 removes the classic autoloader. Retaining older defaults cannot restore it. Declare sprockets-rails explicitly if the application uses it; installing Rails 7 does not require replacing Turbolinks or UJS with Turbo.
The 7.0 defaults select cookies_serializer = :json, cache_format_version = 7.0, SHA-256 digest/key generation and variant_processor = :vips. They also disable partial_inserts and enable raise_on_open_redirects. Separate data readers, native image processing and changed insert/redirect behavior into independently tested decisions.
Rails 7.0 to 7.1
The 2023 defaults set default_column_serializer = nil, select cache format 7.1 and message serializer :json_allow_marshal, and enable use_message_serializer_for_metadata. Selecting JSON output can leave Marshal enabled on reads. Write down the actual allowed reader formats.
Test show_exceptions‘ enum values, transaction callback ordering, the HTML5 sanitizer and any autoload_lib adoption. The renamed local secret file affects development/test; it is not a production key-rotation instruction. Address the deprecation of Rails.application.secrets before the next rung.
Rails 7.1 to 7.2
7.2 in 2024 raises the Ruby floor to 3.1 and removes the secrets API. Test helpers now respect the configured Active Job adapter more consistently. Enqueue-after-commit behavior also depends on job configuration and adapter support; do not silently replace the queue backend.
Review deprecated enum keyword definitions. YJIT’s default depends on the eligible Ruby runtime, and generated browser guards are application code, not an unavoidable rejection policy for every upgraded app. A Ruby minimum does not determine the application’s exact Psych or mail-library bundle.
Rails 7.2 to 8.0
8.0 in 2024 is the next framework checkpoint after cleaning 7.2 deprecations. Solid Queue, Solid Cache, Propshaft and deployment tooling in new applications are separate adoption choices. Keep the current services and asset pipeline explicit while testing the framework change.
Rails 8.0 to 8.1
8.1 in 2025 sorts non-primary-key columns in schema.rb. Its finder check warns or raises when the relation and model provide no usable ordering; primary-key ordering remains valid. Give business queries an explicit order where the result depends on one.
Choose a runtime that both bundles can actually use
The floors are historical constraints, not supported runtime recommendations. Ruby 2.7, 3.0, 3.1 and 3.2 have ended maintenance, as recorded on the Ruby branch-status page. Reproduce old combinations in isolated migration environments and plan a maintained destination.
Where dependency resolution and tests establish an overlap, a runtime step can skip an obsolete intermediate Ruby. Do not turn that into a blanket claim that Rails 5.2 runs on Ruby 3.3. The concrete 7.0/7.1 rehearsal here uses Ruby 3.3.12; the separate 8.0/8.1 schema check uses 3.4.10. Neither establishes Ruby 4.0 readiness.
Ruby 2.7 introduced warnings for the keyword-argument separation completed in Ruby 3.0. Exercise argument-forwarding wrappers and their callers before that runtime step; changing Rails alone does not fix them.
A hop with reviewable commits
The synthetic application keeps Ruby 3.3.12 fixed while moving from Rails 7.0.10 to 7.1.6. These are abbreviated hashes from its actual Git history, included in the download.
8f5ee6f Rails 7.1.6 with load_defaults 7.0
e5291ef Resolve app:update conflicts
00a9d24 Fix deprecated calls; preserve tested data
44bdad8 Begin individual defaults commits
f405d66 Test non-local transaction behavior separately
8ee9abb Change message writer after compatibility check
bin/rails app:update displayed real overwrite conflicts. Its proposed application.rb already retained 7.0 defaults. The explicit resolution preserved test exception behavior as :none, instead of copying :rescuable.
Ordinary tests passed with positional serialize and Parameters/Hash equality warnings. STRICT_DEPRECATIONS=1 bin/rails test exposed four errors. Explicit coder: YAML, type: Hash and permitted .to_h restored the tested contracts. Ten settings then received separate commits and behavior checks. The final suite passed 15 tests and 32 assertions. All 28 defaults have decisions; deferred items keep load_defaults at 7.0.
A standalone probe showed a non-local transaction exit rolling back before its flag and committing afterward. Signed-message tests exposed a different limit. Rails 7.0 read the 7.1 writer’s retained format, but rejected its new :json_allow_marshal output with TypeError. The new reader accepted both. That changes the rollback options; it proves nothing about cookie or cache compatibility.
Use next_rails to maintain separate lockfiles, and test both bundles on each relevant commit. Its compatibility report and RailsBump narrow the dependency investigation. In the synthetic assessment, next_rails 1.7.0 reported no declared Rails 8.1 incompatibilities, but its Ruby 3.4 check flagged the locked sqlite3 1.7.3 arm64-darwin package. The checked RailsBump pages classify the selected Paperclip, rails-observers and factory_bot versions as dependency-compatible with 8.1. Neither report verifies the application’s money paths, callbacks or authorization behavior.
Compare app:update conflicts with local decisions and RailsDiff. Keep a deprecation baseline that fails on newly exercised warnings. Run deterministic seeds before bisecting a failing change. deprecation_toolkit, bundler-audit and Brakeman answer different questions; a clean security scan does not verify the new defaults. Choose tool versions compatible with the rung; current deprecation_toolkit 2.4.0 needs Ruby 3.3 and Active Support 7 or later. FastRuby also published an open-source upgrade workflow on 27 March 2026; this article does not evaluate that workflow.
Give every dependency a verdict
Record the locked version, target Rails/Ruby, evidence URL, used capability, verdict, owner and blocking test. Upgrade a maintained dependency with verified constraints. Fork only when a necessary patch has a maintenance and security owner. Vendor small code only with its license and update obligations understood. Replace when a successor fits the behavior. Delete only after checking transitive dependencies and dynamically loaded paths as well as application references.
| Older dependency | Decision to investigate |
|---|---|
| Paperclip | Archived upstream. A maintained fork or Active Storage requires an explicit attachment/data plan. |
| protected_attributes, deprecated finders | Remove reliance on APIs Rails no longer supports. |
| rails-observers | Extracted gem, not simply “gone”. Inspect callbacks and target-version tests. |
| sass-rails, coffee-rails | Review the actual compiler/build. An old generator default is not evidence of runtime removal. |
| therubyracer, capybara-webkit | Compare the required JS runtime or browser behavior with maintained alternatives. |
| factory_girl, cancan | Follow the rename/successor and verify application configuration. |
| will_paginate, paranoia | Pagy and Discard are choices, not mandatory drop-in replacements. |
Devise 5.0.4 requires Railties 7+, RSpec Rails 8.0.4 requires Rails components 7.2+, and Sidekiq 8.1.7 requires Rack 3.2+. ActiveAdmin 3.5.2 still depends on jquery-rails. These are exact-release constraints, not universal upgrade minimums. The downloadable dependency worksheet links their own metadata and records scope. Forking before checking a maintained release, or deleting a transitive dependency because a search found no constant, creates avoidable work.
Check loading, assets and data separately
The initial ordinary suite passed five tests and nine assertions. bin/rails zeitwerk:check then reported that app/services/price_quote.rb should define PriceQuote; the file defined PriceQuotes. Renaming the constant and adding a service-load test made the check pass. The green suite had only covered paths it loaded.
Review acronyms, custom autoload roots, STI loading and initializer references separately. Reloadable constants may belong in to_prepare; removing every require_dependency blindly can hide engine-loading assumptions. The Zeitwerk migration guide explains these cases.
Retaining Sprockets means declaring its integration and, for Sprockets 4, keeping the required manifest. Retaining Webpacker needs its own maintenance decision. A later Turbo migration has event names, attributes, navigation and form behavior to test. Combining those changes with the framework makes a broken page harder to diagnose.
Preserve historical migration semantics. Adding the appropriate compatibility superclass is different from rewriting old DDL. Test fresh database creation from the maintained schema. Choose structure.sql when database-specific features require it, not because a Rails number automatically forces it. Bigint keys, timestamp nullability and datetime precision depend on the migration version and adapter.
In the executed 8.0.5.1-to-8.1.3.1 SQLite rehearsal of the schema-dumper change, the next migration had no application DDL. The resulting dump moved zeta below updated_at; physical columns, the check constraint and the stored row were unchanged. The schema header and migration version also changed. Review those separately before treating a large diff as formatting.
| Rollback record | Evidence needed before enabling it |
|---|---|
| Cookie, message or cache writer | Old process reads new output; new process reads retained data; expiry/rotation policy |
| Column conversion or destructive DDL | Recoverable copy, concurrent-write behavior, restore procedure and owner |
| Runtime or attachment change | Exact old/new readers and native dependencies tested against representative data |
This catalogue makes rollback a tested property. Keeping old defaults alone does not guarantee that redeploying old code can read everything a new process has written.
When the next step should be something else
An application nobody can deploy needs a reproducible build, repository access, configuration and a restore rehearsal before a framework change. Without those, even returning to the current release is uncertain. If authorization, billing or ledger paths have no useful tests, first capture those behaviors. More framework work cannot supply the missing evidence.
A funded replacement can justify a narrower maintenance plan when its retirement date, data migration, integrations and owner are credible. “We will replace it next year” does not make unsupported software safe. Compare a scoped rebuild with the ladder using the behavior still required, identity, historical data and integration contracts. A smaller screen count alone is not a smaller migration.
JRuby needs its own runtime, adapter and dependency compatibility assessment. A frontend-only goal needs a frontend plan. A hosting move needs a separate data/configuration runbook. None becomes part of the Rails hop simply because an upgrade branch exists.
What to do this week
Choose one business-critical flow and capture its current results. Produce the gem verdict sheet and prove the patched baseline can deploy and recover. Then open the next-minor branch with both lockfiles, retained defaults and a deprecation check that can fail.
Rails 1.0 arrived in December 2005; 2.0 in 2007 strengthened REST conventions; 3.0 in 2010 brought the Merb integration, Bundler and Arel; 4.0 in 2013 brought strong parameters and Turbolinks. That history matters because application assembly changed alongside framework APIs. The ladder separates those accumulated decisions so each can be explained and tested.
Download the upgrade rehearsal and worksheets. Source, lockfiles, limitations and a reproducible commit history are included without a form. The Rails policy currently lists 7 November 2026 as the end of 8.0 security fixes. The support calendar explains the destination decision. Recheck both when planning the actual rollout.