Skip to content

Upgrading from Ruby 3.4.4 to Ruby 4.0.3: a step-by-step guide for Rails apps

TL;DR

Ruby 4.0 breaks less than the version number suggests. The real work is adding gems that left the default set (cgi, ostruct, logger), moving Ractor code to Ractor::Port, replacing pipe-opens with IO.popen, and fixing Set inspect and backtrace assertions. In exchange you get a faster allocator, a leaner GC and a patched ERB. Keep YJIT on, ZJIT off.

Ruby 4.0 is the first major version bump in over a decade, but most of the work is under the hood. Here is what actually breaks when moving a Rails app from 3.4.4 to 4.0.3, what you gain, and a phased upgrade plan you can run in a week.

Published 9 min read

Also available in Português (Brasil)

Share

Why move now

Ruby 4.0.0 shipped on December 25, 2025. The 4.0.x line has been receiving patch releases on a roughly bi-monthly cadence since, and 4.0.3 (April 21, 2026) is a good landing point: it contains a single change, ERB 6.0.1.1, which closes CVE-2026-41316. That CVE affects any app that calls Marshal.load on untrusted data while erb and activesupport are both loaded, which describes nearly every Rails app with a cache store. If you are still on 3.4.4, you are also behind on the 3.4 line's own security patches, so the upgrade solves two problems at once.

Later 4.0.x releases (4.0.4 and up) are drop-in replacements for 4.0.3. Everything below applies to them unchanged; pin whatever is current when you read this.

The good news: 4.0 is a "restructuring" release. There are very few language changes, and the breaking changes are concentrated in three areas: gems that left the default set, Ractor's API, and a handful of removed legacy behaviours. A typical Rails 8.x app upgrades in a day of focused work plus a soak period.

What breaks

1. Default gems promoted to bundled gems

This is the one that bites almost everyone. In 4.0, these are no longer loadable without a Gemfile entry:

ostruct, logger, benchmark, pstore, irb, reline, readline, rdoc, fiddle, win32ole

On top of that, cgi was removed from the default gems entirely. Only cgi/escape remains (CGI.escape, CGI.escapeHTML and friends). Anything that needs CGI::Cookie, CGI.parse or the session helpers now needs the cgi gem.

The failure mode is sneaky: it works locally because an older Ruby left the gem installed, then fails in a clean Docker build. The Rails issue tracker had a thread in early 2026 where Rails 8.1.1 "wasn't ready for Ruby 4"; the actual cause was sprockets-rails and sitemap_generator depending on cgi, and gem "cgi" in the Gemfile fixed it.

Add what you actually use:

# Gemfile
gem "cgi"       # if sprockets, sitemap_generator, or any gem still requires it
gem "ostruct"   # if you OpenStruct anywhere, including specs
gem "logger"    # usually pulled in by activesupport, but pin it explicitly
gem "benchmark" # if you require it in scripts or rake tasks

Find out which ones you need before switching Ruby:

grep -rn "require ['\"]\(ostruct\|logger\|benchmark\|pstore\|cgi\|irb\|fiddle\)" app lib config spec test
RUBYOPT="-W:deprecated" bundle exec rspec 2>&1 | grep -i "bundled gem"

Ruby 3.4 already emits warnings for these, so the second command gives you the full list while still on 3.4.4.

2. Ractor API rewrite

Ractor.yield, Ractor#take, Ractor#close_incoming and Ractor#close_outgoing are gone, replaced by Ractor::Port. If you have no Ractor code, skip this. If you do:

# Ruby 3.4
r = Ractor.new { Ractor.yield compute }
result = r.take

# Ruby 4.0
r = Ractor.new { compute }
result = r.value          # like Thread#value

# Explicit channels
port = Ractor::Port.new
r = Ractor.new(port) { |p| p << compute }
result = port.receive

Ractor#join, #value, #monitor and Ractor.shareable_proc are new. Ractor.select now only accepts Ractors and Ports.

3. Removed behaviours

  • Kernel#open("| cmd") and the IO class methods no longer spawn a process on a leading pipe. Use IO.popen. Grep for open("| and open('|.
  • --rjit is removed. If you have it in RUBYOPT or a Dockerfile, switch to --yjit.
  • Process::Status#& and #>> are removed (deprecated since 3.3).
  • SortedSet is removed from set. Add the sorted_set gem.
  • *nil no longer calls nil.to_a. [*nil] still gives [], but a custom nil.to_a monkey patch (rare, but it exists in old codebases) stops being called.

4. Behavioural changes that break tests, not code

  • Set is now a core class. Set.new([1,2]).inspect returns Set[1, 2] instead of #<Set: {1, 2}>. Snapshot tests and anything parsing inspect output will fail.
  • Net::HTTP no longer sets Content-Type: application/x-www-form-urlencoded automatically on requests with a body. Some servers reject the request. Set the header explicitly in your HTTP clients or check your Faraday/HTTParty versions, which handle it for you.
  • Backtraces for "wrong number of arguments" now include the receiver class (Foo#bar instead of bar), and <internal:*> frames are gone. Any error-grouping rule in Sentry, Rollbar or Datadog that matches on the old frame text needs updating.
  • A line starting with &&, ||, and or or now continues the previous line, the same as a leading .method chain. Valid code stays valid; the parser just accepts more.
  • Binding#local_variable_get(:_1) raises NameError. Numbered and it parameters moved to Binding#implicit_parameters. Only debuggers and metaprogramming-heavy gems care.
  • Proc#parameters reports anonymous optional parameters as [:opt] rather than [:opt, nil].

5. Native extensions

openssl 4.0.0 ships as a default gem and the C API dropped rb_path_check and made rb_thread_fd_close a no-op. nokogiri, pg, mysql2, bcrypt and ffi all have 4.0-compatible releases, but a stale compiled copy in a cached bundle will segfault or fail to load. Always run bundle pristine after switching, and clear CI bundle caches.

What you gain

Nothing here requires a code change; you get it by deploying.

Allocation and GC. Class#new is faster across the board, with the biggest gains on keyword-argument initializers, which is every ActiveModel and service object you have. GC size pools now grow independently, so a heap full of small strings no longer drags large-object pages along with it, and sweeping large-object pages is faster. The id2ref table is built lazily on first use, which speeds up both object_id and sweeping for apps that never call it.

Instance variables on non-plain objects. Strings, Arrays and TypedData objects with ivars (think Pathname, ActiveSupport::SafeBuffer, anything using instance_variable_set on a String) now store them in an internal fields object, with faster access.

Ractor internals. Lock-free frozen-string and symbol tables, mostly lock-free method cache lookups, less contention on allocation. You benefit even without Ractors because the global lock was on hot paths.

Core-class promotions. Set and Pathname are core classes now, so no autoload hit on first use and a C API (rb_set_add, rb_set_lookup, etc.) for extensions.

Stdlib. json 2.18, psych 5.3.1, prism 1.7, openssl 4.0, net-http 0.9.1, minitest 6.0. Unicode 17.0 for String and Regexp. Socket.tcp and TCPSocket.new accept open_timeout: and raise IO::TimeoutError consistently. Math.log1p and Math.expm1. Array#rfind.

YJIT. RubyVM::YJIT.enable now takes mem_size: and call_threshold: so you can tune it from an initializer instead of environment variables.

ZJIT and Ruby::Box: not yet. ZJIT is the method-based successor to YJIT, enabled with --zjit. In 4.0 it beats the interpreter but is slower than YJIT, and the core team says plainly not to run it in production until 4.1. Keep --yjit. Ruby::Box (RUBY_BOX=1) isolates monkey patches and class definitions per box, aimed at isolated test runs and comparing dependency updates side by side. Experimental; interesting to try, not something to build on.

Step-by-step upgrade

Phase 1: prepare on 3.4.4 (no Ruby change yet)

  1. Green test suite on 3.4.4. If it isn't green, you won't know which failures are new.
  2. Run the suite with deprecation warnings as errors and fix every warning:
RUBYOPT="-W:deprecated" bundle exec rspec
  1. Add bundled gems to the Gemfile (section 1 above). Commit and deploy this on 3.4.4; it is a no-op there.
  2. Grep for open("|, Ractor.yield, .take, SortedSet, --rjit, Process::Status. Fix each.
  3. Audit inspect-based assertions on Sets and custom error-grouping rules in your APM.
  4. Check bundle outdated for gems with required_ruby_version < 4. Upgrade or replace them now:
bundle exec ruby -e 'Bundler.load.specs.each { |s| puts "#{s.name} #{s.required_ruby_version}" if s.required_ruby_version.to_s =~ /< ?4/ }'

Phase 2: switch locally and in CI

  1. Bump the version:
echo "4.0.3" > .ruby-version
rbenv install 4.0.3   # or: mise install ruby@4.0.3 / asdf install ruby 4.0.3
  1. Update the Gemfile:
ruby file: ".ruby-version"
  1. Rebuild the bundle cleanly:
rm -rf vendor/bundle   # or wherever your path is
bundle install
bundle pristine
  1. Run the suite. Expect a small set of failures: Set inspect output, Net::HTTP content types, backtrace-matching specs, the odd gem that still requires cgi.
  2. Update CI. For GitHub Actions, ruby/setup-ruby reads .ruby-version; just invalidate the bundle cache key. Do not add a Rust toolchain unless you want ZJIT; the official Ruby images already ship YJIT.
  3. Dockerfile:
ARG RUBY_VERSION=4.0.3
FROM ruby:${RUBY_VERSION}-slim AS base
ENV RUBY_YJIT_ENABLE=1

If you build Ruby yourself, 4.0 needs a C11 compiler and, for ZJIT only, Rust 1.85+. YJIT needs Rust only at build time and the official images handle it.

Phase 3: staging and production

  1. Deploy to staging with YJIT on and run your normal smoke suite plus a load test against the 3.4.4 baseline. Capture p50/p95 and RSS per Puma worker, not just throughput.
  2. Confirm your error tracker groups the new backtrace format correctly. Trigger one ArgumentError on purpose.
  3. Check outbound HTTP integrations that send form bodies through raw Net::HTTP.
  4. Re-baseline memory alerts. GC changes typically lower RSS; if yours rises, look for a gem with its own allocator.
  5. Production rollout. With Kamal, that is a tagged build and a kamal deploy; keep the 3.4.4 image around for kamal rollback. Watch error rate and p95 for 48 hours before deleting the old image.

Rails-specific notes

  • Rails 8.1.x and 8.0.x current patch releases run on Ruby 4.0. The failures reported against Rails 8.1 in the wild were all gems depending on removed defaults, not Rails itself. Rails 7.2 requires Ruby 3.1+ and works on 4.0 for most apps, but expect more gem churn. Rails 7.1 and older are out of support; upgrade Rails first.
  • ActiveSupport::OrderedOptions and config.x do not use OpenStruct, but many apps do in specs and seeds. Either add the gem or replace with Struct.new(..., keyword_init: true) / Data.define.
  • Cache stores that use Marshal are exactly why 4.0.3's ERB fix matters. Upgrading Ruby patches it; upgrading only the erb gem also works if you are stuck on 4.0.2.
  • Set being core means require "set" is a no-op; leave it in place for gems that still call it.

Rollback plan

Keep it boring: the previous image tag, the previous Gemfile.lock and a one-line .ruby-version revert. Nothing in 4.0 changes on-disk formats (cache entries, Marshal dumps, session cookies) in a way that stops 3.4.4 from reading them, so rollback is a redeploy, not a migration.

Summary

The 3.4 to 4.0 jump is smaller than the version number suggests. The checklist in one breath: add the bundled gems, swap Ractor calls to Ports, replace pipe-opens with IO.popen, fix Set and backtrace assertions, bundle pristine, keep YJIT on and ZJIT off, soak for two days. In exchange you get a faster allocator, a leaner GC, patched stdlib, and a runtime that is ready for Ractors and ZJIT when 4.1 arrives.

Sources

  1. Ruby 4.0.0 Released (ruby-lang.org) www.ruby-lang.org
  2. Ruby 4.0.3 Released (ruby-lang.org) www.ruby-lang.org
  3. Ruby 3.4.4 Released (ruby-lang.org) www.ruby-lang.org
  4. Reading Ruby 4.0 NEWS with Pros (Koichi Sasada) dev.to
  5. Ruby 4.0 Adoption Guide (RubyLearning) rubylearning.com
  6. rails/rails #56457: Rails 8.1.1 and Ruby 4 github.com
  7. Upgrading Ruby on Rails (Rails Guides) guides.rubyonrails.org

FAQ

Do I have to land on exactly Ruby 4.0.3, or can I use a newer 4.0.x?

Use whatever 4.0.x is current. 4.0.3 is the first release that includes the ERB fix for CVE-2026-41316, which is why it is the minimum worth targeting, but 4.0.4 and later are drop-in replacements and everything in the guide applies to them unchanged.

Why does my app work on Ruby 4.0 locally but fail in a clean Docker build?

A gem that left the default set (most often `cgi`, `ostruct` or `logger`) is still installed on your machine from an older Ruby, so `require` succeeds locally. A fresh image has none of them. Add the gems you actually use to the Gemfile and the build becomes deterministic.

Is Rails 8.1 ready for Ruby 4.0?

Yes. Rails 8.1.x and 8.0.x current patch releases run on Ruby 4.0. The failures reported in early 2026 were caused by gems such as `sprockets-rails` and `sitemap_generator` depending on the removed `cgi` default gem, not by Rails itself.

Should I enable ZJIT in production on Ruby 4.0?

No. In 4.0, ZJIT beats the interpreter but is still slower than YJIT, and the core team recommends waiting for 4.1 before running it in production. Keep `--yjit` (or `RUBY_YJIT_ENABLE=1`) on.

I have no Ractor code. Does the Ractor::Port rewrite affect me?

Not directly. You can skip the API migration entirely. You still benefit from the internal work behind it, since lock-free symbol and frozen-string tables and a mostly lock-free method cache reduce contention on hot paths even in single-Ractor apps.

What is the safest way to roll back if something goes wrong in production?

Redeploy the previous image. Ruby 4.0 does not change on-disk formats such as cache entries, Marshal dumps or session cookies in a way that 3.4.4 cannot read, so a rollback is the previous image tag, the previous `Gemfile.lock` and a `.ruby-version` revert. With Kamal that is `kamal rollback`.

Comments

Sign in with GitHub to comment.

No comments yet.