Desk2Mob

Desk2Mob

Desk2Mob

Ruby 4.0: What's New and What Breaks on Upgrade

In its December 25, 2025 announcement, keeping to the tradition of a release every Christmas Day, the Ruby core team put out Ruby 4.0.0. A major version bump always raises the question of what actually justifies the "4." The honest answer is that this is less a dramatic break and more a consolidation: two headline features (Ruby Box and ZJIT), a batch of Ractor work that moves true parallelism closer to leaving its experimental status, and a long tail of language and standard-library cleanups. This article walks through the parts that matter day to day, with code, and is explicit about which older-Ruby annoyances each change is there to solve.

One note up front on versions. Everything here targets Ruby 4.0. Where a feature removes an API you may already depend on, the snippets favor the new way and the breaking-changes section lists exactly what to migrate, so you see what breaks on upgrade rather than discovering it in CI.

Ruby Box: isolation inside a single process

The marquee feature is Ruby::Box, an experimental mechanism for isolating definitions within one Ruby process. You turn it on with the RUBY_BOX=1 environment variable. Definitions loaded inside a box - class and module definitions, monkey patches, changes to global and class variables, even loaded native and Ruby libraries - stay contained in that box and don't leak out to the rest of the process. Note the scope: a box isolates definitions, not every process-wide side effect. Changes to ENV, the filesystem, or other shared OS state are not sandboxed.

The problem it solves is an old and familiar one: shared definitions in a single global namespace. In Ruby before 4.0, there was effectively one namespace per process. If a test monkey-patched String#blank? to probe some edge case, that patch bled into every other test in the suite. And loading two versions of the same dependency side by side to compare their behavior was impractical - requiring an already-loaded file just returns false rather than reloading it, and activating a conflicting gem version raises Gem::LoadError. The usual workaround was to spin up separate processes and pay the full startup and memory cost for each one.

Shell
$ RUBY_BOX=1 ruby patch_demo.rb
Ruby
# A monkey patch applied inside a box stays inside that box.
box = Ruby::Box.new
box.eval(<<~RUBY)
  class String
    def shout = upcase + "!"
  end
  "hello".shout   # => "HELLO!"  (works in here)
RUBY

# Out here, the patch never happened.
"hello".respond_to?(:shout)   # => false

The release notes call out the use cases plainly: running test cases in a box so their monkey patches can't corrupt neighbors, running web-app boxes in parallel to do blue-green deployment within a single process, and evaluating a dependency upgrade by diffing responses from an old box and a new box over a window of time. It's also positioned as the low-level foundation a future higher-level "package" API could be built on. Treat it as experimental for now - the API is explicitly not frozen - but the direction is clear: Ruby is giving you a way to contain blast radius without reaching for a second process.

ZJIT: the next-generation JIT compiler

Ruby 4.0 ships ZJIT, a new just-in-time compiler positioned as the successor to YJIT. You enable it with the --zjit flag (or RubyVM::ZJIT.enable at runtime), and building Ruby with ZJIT support requires Rust 1.85.0 or newer.

Shell
# Enable at startup
$ ruby --zjit app.rb

Here's the part worth being precise about, because it's easy to misread a new JIT as "just turn it on." As of 4.0.0, ZJIT is faster than the plain interpreter but not yet as fast as YJIT. The core team's own guidance is to experiment with it but hold off on production.

The reason it exists isn't a quick win today - it's a longer play. YJIT's design made it hard for outside contributors to work on and limited how large a chunk of code it could compile at once. ZJIT is built as a more traditional method-based compiler with an SSA intermediate representation and bigger compilation units, which raises the performance ceiling and lowers the barrier to contribution. So the problem being solved here is architectural: YJIT was approaching the limits of what its design could do. The announcement states a goal of making ZJIT overtake YJIT and become production-ready in Ruby 4.1. For now, YJIT remains the one you deploy.

Ractor: parallelism moves toward stable

Ractor arrived in Ruby 3.0 as the answer to the GIL - a way to run Ruby code on multiple cores for real. It has carried an "experimental" label ever since, partly because of stability issues and partly because its communication API was awkward. Ruby 4.0 does a lot of work here, and the announcement states the aim of removing the experimental status in 2026.

The biggest API change is the introduction of Ractor::Port, a proper synchronization primitive for sending and receiving messages. This is a solution to a real pain point in the old model: message passing with Ractor.yield and Ractor#take was clumsy and error-prone to coordinate. Those two methods have been removed, along with Ractor#close_incoming and Ractor#close_outgoing, so this is a genuine breaking change you'll hit on upgrade if you used them.

Ruby
port1 = Ractor::Port.new
port2 = Ractor::Port.new

Ractor.new port1, port2 do |port1, port2|
  port1 << 1
  port2 << 11
  port1 << 2
  port2 << 12
end

2.times { p port1.receive }   # => 1, 2
2.times { p port2.receive }   # => 11, 12

A port gives you explicit receive, send (aliased <<), close, and closed? methods, so the direction and lifecycle of a channel are clear rather than implied. Alongside that, Ractor#join and Ractor#value were added to wait for a Ractor to finish - deliberately mirroring Thread#join and Thread#value, which makes the mental model much easier if you already know threads.

Because Ractor.yield and Ractor#take were removed, a producer/consumer pair that used them needs rewriting. There's one semantic difference to watch when migrating a continuous producer: unlike Ractor.yield, which blocks until a consumer takes the value, sending to a port (port.send / <<) queues the message without waiting for the receiver to consume it. Here is the old 3.x style next to the 4.0 replacement:

Ruby (before, 3.x - removed in 4.0)
r = Ractor.new do
  Ractor.yield 1
  Ractor.yield 2
end

p r.take   # => 1
p r.take   # => 2
Ruby (after, 4.0)
port = Ractor::Port.new

Ractor.new port do |port|
  port << 1
  port << 2
end

p port.receive   # => 1
p port.receive   # => 2

There's also Ractor.shareable_proc (and Ractor.shareable_lambda), which solves a specific friction point: sharing a Proc between Ractors used to be fiddly because of the strict shareability rules on captured state.

Ruby
greeter = Ractor.shareable_proc { |name| "Hello, #{name}" }

r = Ractor.new(greeter) do |fn|
  fn.call("world")
end

p r.value   # => "Hello, world"

Under the hood, the less glamorous changes are arguably the most important: lock-free internal hash sets for frozen strings and the symbol table, method-cache lookups that avoid locking in the common case, per-Ractor allocation counters to cut CPU-cache contention, and a pile of fixed deadlocks and race conditions. The old reality was that contention on a single global lock quietly capped how much parallelism you actually got; these changes attack that directly. That's the unglamorous work that turns "parallelism in theory" into "parallelism that scales."

Small language changes that improve everyday readability

Two language-level tweaks are worth knowing because they change how everyday code reads.

First, logical binary operators (||, &&, and, or) at the start of a line now continue the previous line, the same way a leading dot already does for method chains. This solves a genuine readability annoyance: breaking a long conditional across lines used to force a trailing operator, which buries the operator at the ragged right edge where it's easy to miss.

Ruby
# Ruby 4.0 - operator leads the continued line, easy to scan
if condition1
   && condition2
   && condition3
  do_the_thing
end

Second, splatting nil no longer calls nil.to_a. This brings *nil in line with how **nil already refused to call nil.to_hash - a consistency fix that removes a small surprise from the splat rules.

Core class additions

A handful of the core-class changes are the kind you reach for in everyday code.

Array#rfind is a dedicated operation for finding the last matching element - what you previously wrote as array.reverse_each.find. The older form already avoids building a reversed array; rfind is simply a single, more efficient method that says what it does. There's also a dedicated Array#find override that's faster than the generic Enumerable#find for arrays. The example uses distinct records so the result is clearly the last match, not just any match:

Ruby
edits = [
  { id: 1, kind: :edit },
  { id: 2, kind: :view },
  { id: 3, kind: :edit },
]

# Find the last edit, searching from the back.
edits.rfind { |e| e[:kind] == :edit }   # => {id: 3, kind: :edit}

Kernel#inspect now honors an optional instance_variables_to_inspect method, which is a neat answer to a recurring problem: leaking secrets into logs. Before this, customizing inspect to hide a password or token meant hand-writing the whole method. Now you just declare which ivars are safe to show.

Ruby
class DatabaseConfig
  def initialize(host, user, password)
    @host = host
    @user = user
    @password = password
  end

  private def instance_variables_to_inspect = [:@host, :@user]
end

conf = DatabaseConfig.new("localhost", "root", "hunter2")
conf.inspect
Output
#<DatabaseConfig:0x0000000104def350 @host="localhost", @user="root">

The @password ivar is simply absent from the output. This controls only the object's default inspect string, so it reduces accidental exposure when logging the object itself - it doesn't sanitize other logging or serialization paths that read the attribute directly.

Two promotions landed as well: Set is now a core class rather than an autoloaded stdlib class, and Pathname graduated from a default gem to a core class. The practical upshot for Set is a cleaner inspect output - Set[1, 2, 3] instead of the old #<Set: {1, 2, 3}>. Be aware this move removed set/sorted_set.rb, so SortedSet is no longer autoloaded; if you relied on it, you now install the sorted_set gem and require 'sorted_set' explicitly.

On the diagnostics side, ErrorHighlight now shows code snippets for both sides of an ArgumentError - the call site and the method definition - so a "wrong number of arguments" error points you at the signature you got wrong, not just the line that called it.

Output
test.rb:1:in 'Object#add': wrong number of arguments (given 1, expected 2) (ArgumentError)

    caller: test.rb:3
    | add(1)
      ^^^
    callee: test.rb:1
    | def add(x, y) = x + y
          ^^^

The breaking changes to plan for

A major version is the right place to remove long-deprecated behavior, and 4.0 does. If you're upgrading a real codebase, these are the ones most likely to bite:

  • Ractor removals. Ractor.yield, Ractor#take, Ractor#close_incoming, and Ractor#close_outgoing are gone. Migrate to Ractor::Port and Ractor#join/#value as shown above.
  • Pipe-prefixed I/O is removed. The old trick of passing a leading | to Kernel#open or IO class methods to spawn a subprocess no longer works. Use Process.spawn, IO.popen, or backticks explicitly - which is clearer about the fact that you're running a command anyway.
  • The CGI library left the default gems. Out of the box, Ruby now ships only the escaping helpers under cgi/escape (CGI.escape, CGI.escapeHTML, CGI.escapeURIComponent, and their unescape counterparts) - so if that's all you use, require "cgi/escape" and you're done. Applications that need the full library can still get it by adding gem "cgi" to the Gemfile.
  • Net::HTTP no longer auto-sets Content-Type. Requests with a body (like POST or PUT) used to get application/x-www-form-urlencoded applied automatically when you didn't set the header. That default is gone, so if you relied on it, set the header yourself or risk a server rejecting the request.
  • ObjectSpace._id2ref is deprecated, and Process::Status#& / #>> have been removed.
  • Windows: MSVC older than 14.0 is no longer supported, so Visual Studio 2015 or later is required.

There's also a useful backtrace improvement that's technically a compatibility change: internal frames no longer clutter your stack traces. A fetch_values error used to drag in a pile of <internal:array> lines; now C-implemented methods appear as if they live in the Ruby source, so the trace points at your code.

If you're an application developer, don't overlook that Ruby 4.0 bundles RubyGems and Bundler 4. Bundler 4 removes several long-deprecated bundle install options - notably --path, --without, and --deployment, which are now set through bundle config instead. If your CI scripts or deploy tooling still pass those flags, they'll break on upgrade. The official Upgrading to RubyGems/Bundler 4 guide covers the full list and the config equivalents.

So why does this release matter?

What distinguishes Ruby 4.0 from a routine point release is where the effort went: the two experimental features point at the next few years rather than today. Ruby Box offers in-process isolation of definitions as the foundation for future packaging work, and ZJIT is a ground-up compiler whose stated goal is to overtake YJIT in 4.1. Neither carries a production guarantee yet - and only ZJIT has a public 4.1 target, while Ruby Box has no equivalent timeline - so treat both as things to try and measure, not to deploy.

The other changes offer practical reasons to upgrade. Lean on the Ractor stability work, the Set and Pathname promotions, the readability tweaks, and the cleaner backtraces. Before you ship the bump, grep for the removed Ractor methods (Ractor.yield, Ractor#take), pipe-prefixed open calls, direct CGI usage, and the retired bundle install flags - those are the changes most likely to turn a smooth upgrade into a surprising one.

Posted on November 06, 2026 by Desk2Mob in Ruby, ruby-4, zjit, ractor


All Posts