Ruby 4.0: A lot of restructuring under the hood, few new features

The new major version with a new JIT compiler, a revised parallelization API, and a maturing type system paves the way for the next decade.

listen Print view
Ruby on a gray background

(Image: Erzeugt mit Midjourney durch heise online)

17 min. read
By
  • Stefan Wintermeyer
Contents

On December 21, 2025, the Ruby language turned 30 years old – and for about 20 years, its creator, Yukihiro Matsumoto (Matz), has released a new major version every Christmas. On December 25, 2025, there was even one with the round version number 4.0. To preemptively state, this is more decoration and due to the anniversary than actually justified by numerous new features. However, since Ruby does not follow strict semantic versioning anyway and avoids major breaking changes like the devil avoids holy water (at least since Ruby 1.9), this is legitimate.

Stefan Wintermeyer

Stefan Wintermeyer is a freelance consultant and trainer. He works with Phoenix Framework, Ruby on Rails, web performance, and Asterisk.

On the other hand, 2025 was an exciting year for Ruby and the Ruby on Rails world. So this article, in addition to looking ahead to Ruby 4, also looks back at what Ruby has achieved technically in recent months and places the newly released version in this context. Because although the prejudice of Ruby being a slow language is hard to eradicate, the language has developed impressive performance through years of continuous development.

The Just-In-Time compiler YJIT, which has been significantly optimized again with Ruby 3.4, achieves a performance increase of 92 percent compared to the interpreter in Shopify benchmarks. The practical proof came on Black Friday 2025: Shopify processed purchases from 81 million customers with its Ruby on Rails infrastructure. The peak load was 117 million requests per minute on the application servers, while the databases handled 53 million read accesses and 2 million write accesses per second.

However, work on future performance optimizations is also progressing. The most technically significant innovation in Ruby 4 is ZJIT, an experimental method-based JIT compiler developed by the same team at Shopify that developed YJIT. ZJIT was merged into the master branch in May 2025 after Matz's approval at the RubyKaigi conference.

ZJIT is fundamentally different architecturally from YJIT. While YJIT compiles the bytecode of the Ruby VM YARV directly into low-level IR, processing one basic block after another (Lazy Basic Block Versioning), ZJIT uses Static Single Assignment Form (SSA) as its High-Level Intermediate Representation (HIR) and compiles complete methods at once. This architecture is intended to pave the way for broader community contributions and, in the long term, enable the storage of compiled code between program executions.

Incidentally, the name ZJIT has no specific meaning; it simply stands for the successor to YJIT. Internally, ZJIT is referred to as the "scientific successor" because its architecture corresponds to classic compiler textbooks, making it easier to understand and extend. The compiler is classified as experimental and currently offers no advantages in production projects. Those who want to work with it must rebuild Ruby with the configure option --enable-zjit and call Ruby with the option --zjit during execution.

Since Ruby 3.4, it has been an elegant implicit block parameter for one-liners. It is more readable than the numbered parameters (_1, _2) that have existed since Ruby 2.7 and saves explicit parameter declaration. The classic declaration with an explicit parameter

users.map { |user| user.name }

and with an implicit numbered parameter

users.map { _1.name }

is therefore supplemented by

users.map { it.name }

This is particularly intuitive and practical in method chaining:

files
  .select { it.size > 1024 }
  .map { it.basename }
  .sort { it.downcase }

The identifier it reads like natural language and makes the code self-documenting. Important: it only works in blocks with exactly one parameter. For multiple parameters, _1, _2, or explicit names remain the correct choice.

The splat operator (*) unpacks arrays into individual elements – for example, to pass the elements from [1, 2, 3] as three separate arguments to a method. From Ruby 4.0 onward, the expression *nil no longer calls nil.to_a but directly returns an empty array. This corresponds to the behavior of the double-splat operator (**) for hashes, where **nil has not called nil.to_hash for a long time. This unification makes the behavior more consistent and less surprising. This is evident, for example, when you want to insert optional elements, such as from a database query, into an array:

optional_tags = nil

With Ruby 4.0, this works cleanly – *nil becomes nothing and does not need to be explicitly caught:

post = { title: "Ruby 4.0", tags: ["news", *optional_tags, "ruby"] }
#=> { title: "Ruby 4.0", tags: ["news", "ruby"] }

The binary logical operators ||, &&, and, and or at the beginning of a line now continue the previous line – analogous to the fluent dot style in method chaining. This allows for more elegant formatting of conditions, similar to method chaining:

result = first_condition
   second_condition
  && third_condition

This change allows for better readability of longer logical expressions without having to use backslashes or parentheses for line continuation.

Ractors are Ruby's answer to the problem of true parallelism. Unlike threads, which are serialized by the Global VM Lock (GVL), Ractors can actually run in parallel on multiple CPU cores. The name is a portmanteau of Ruby and Actor – the concept is based on the Actor model, where isolated units communicate exclusively via messages. Ractors are still considered experimental in Ruby 4.0. The IRB displays a corresponding warning.

The GVL has long been Ruby's biggest weakness for CPU-intensive tasks. While threads could parallelize I/O operations because the lock is released during I/O, calculations always ran sequentially. Ractors circumvent this problem because they no longer share a common GVL; each Ractor executes code independently. Ruby only synchronizes internally at specific points.

Each Ractor has its own memory space. Objects cannot be shared between Ractors – unless they are immutable. This strict isolation eliminates race conditions by design (see Listing 1):

$ irb
irb(main):001> r = Ractor.new { 2 + 2 }
(irb):1: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
=> #<Ractor:#2 (irb):1 terminated>
irb(main):002> r.join
=> #<Ractor:#2 (irb):1 terminated>
irb(main):003> puts r.value
4
=> nil

The example shows the typical Ractor lifecycle: Ractor.new starts a new Ractor with the provided block, join waits for its completion, and value returns the result – here, the calculated sum 4. For such a simple calculation as 2 + 2, the Ractor has already finished (terminated) before the call to join occurs. For completeness, the example still shows the complete process – for longer calculations, join is essential to wait for the result.

irb(main):004> def fib(n) = n < 2 ? n : fib(n-1) + fib(n-2)
irb(main):005* ractors = [35, 36, 37, 38].map do |n|
irb(main):006*   Ractor.new(n) { fib(it) }
irb(main):007> end
=> 
[#<Ractor:#3 (irb):6 running>,
...
irb(main):008> results = ractors.map(&:value)
=> [9227465, 14930352, 24157817, 39088169]

Ractors show their strength in CPU-intensive tasks. The example in Listing 2 demonstrates this. It calculates medium-sized Fibonacci numbers in parallel. On a quad-core system, this example runs almost four times faster than the sequential version. In the Tarai benchmark – a classic recursion test – four parallel Ractors achieve a 3.87x speedup compared to sequential execution.

Ruby 4.0 fundamentally revises the Ractor API. The old methods Ractor.yield, Ractor#take, and the close_* methods have been removed. They are replaced by Ractor::Port for communication between Ractors.

The most important rule: A port can only be received by the Ractor that created it. Therefore, for bidirectional communication, each Ractor needs its own port (see Listing 3)

# Port des Haupt-Ractors fĂĽr Antworten
main_port = Ractor::Port.new

worker = Ractor.new(main_port) do |reply_port|
  # Worker erstellt eigenen Port fĂĽr eingehende Nachrichten
  worker_port = Ractor::Port.new
  reply_port.send(worker_port)  # teilt seinen Port mit

  num = worker_port.receive     # empfängt von eigenem Port
  reply_port.send(num * 2)      # sendet Ergebnis zurĂĽck
end

worker_port = main_port.receive  # erhält Worker-Port
worker_port.send(21)             # sendet Aufgabe
puts main_port.receive           # => 42

The strict isolation of Ractors means that not every object can be exchanged between them. Ruby distinguishes between shareable and non-shareable objects. Immutable objects are automatically shareable:

Ractor.shareable?(42)       #=> true
Ractor.shareable?(:symbol)  #=> true
Ractor.shareable?("text")   #=> false

However, objects can be explicitly made shareable using Deep Freeze:

config = Ractor.make_shareable({ host: "localhost" })

New in Ruby 4.0 are Shareable Procs and Ractor-local storage. This allows for more complex scenarios where functions need to be shared between Ractors or data needs to be persisted within a Ractor.

Ruby was and is a dynamically typed language, checking variable types only at runtime instead of during compilation. However, work on the optional type system shows that static analysis and dynamic flexibility can coexist. Ruby 4.0 marks an significant milestone on this path.

RBS (Ruby Signature) is the official format for type definitions. Unlike annotations in the source code, RBS definitions are maintained in separate .rbs files – similar to .d.ts files in TypeScript. This approach has a crucial advantage: existing Ruby code does not need to be changed, and teams can introduce type definitions incrementally (see Listing 4).

# sig/user.rbs
class User
  attr_reader name: String    # Pflichtfeld: muss String sein
  attr_reader age: Integer?   # Optional: Integer oder nil

  # RĂĽckgabe: void (kein RĂĽckgabewert relevant)
  def initialize: (String, ?Integer) -> void

  # Prädikatmethode: gibt bool zurück
  def adult?: -> bool
end

Steep, the reference type checker for RBS, finds bugs that would otherwise only be apparent at runtime. Listings 5 and 6 show a complete example.

These errors would only be detected at runtime without a type system. With RBS and Steep, they are detected during development or at the latest in the CI pipeline. This saves debugging time and prevents such bugs from reaching production at all.

AI-powered coding assistants like GitHub Copilot, Cursor, or Claude now generate entire functions and classes at the touch of a button. However, Large Language Models hallucinate – they invent method names, confuse parameter order, or pass strings where integers are expected. In dynamically typed languages like Ruby, such errors are only detected at runtime – in the worst case, in production.

This is where the RBS type system unfolds its full value: Steep acts as a safety net during agentic coding. If an assistant generates a function that calls User.find_by_email with an integer instead of a string, Steep reports the error immediately – even before the code is executed. The feedback loop shortens from "runtime error after deployment" to "red underline in the editor."

More importantly: RBS definitions improve the quality of AI suggestions themselves. Coding assistants use context – and type signatures are extremely dense context. An RBS file not only documents which types a method accepts but also communicates the intention of the code. AI models trained on type definitions generate more precise code because they understand the constraints. The interplay in practice:

  1. Developer writes RBS signature for new method
  2. AI assistant generates implementation based on signature
  3. Steep validates generated code against type definition
  4. Errors are immediately visible, correction occurs before commit

For teams working intensively with AI assistants, a type system is often no longer an optional addition – it is the quality assurance that prevents hallucinated code from entering the codebase. Ruby with RBS offers the best of both worlds here: the flexibility of a dynamic language with the security of static analysis, precisely where it is needed.

Videos by heise

The long-term goal is an ecosystem of gradual typing. Developers should be able to decide for themselves how much static analysis they want – from none to strictly everywhere. Unlike TypeScript, which extends JavaScript with types, Ruby remains syntactically unchanged. The types live in separate files and are completely optional.

The building blocks for this ecosystem are already in place:

  • RBS Collection: A growing library of type definitions for popular gems. The IDE RubyMine automatically downloads these and uses them for autocompletion and error checking. In VS Code, manual setup via rbs collection install is necessary; thereafter, autocompletion works with the Ruby LSP Extension.
  • Steep: The official static type checker that checks RBS definitions against source code and can be integrated into CI pipelines.
  • TypeProf: An inference tool that automatically generates RBS definitions from existing code – ideal for the gradual introduction of types into legacy projects.
  • Sorbet Integration: Stripe's alternative type checker increases RBS compatibility, improving interoperability between the two systems.

A parser is the program that reads source code and translates it into a structured representation – the Abstract Syntax Tree (AST). Only through this tree structure can the interpreter understand what the code means. Since Ruby 3.4, Prism has been the standard parser, replacing the 30-year-old parse.y. Prism is written in C99 without external dependencies, is fault-tolerant, and portable.

The benchmarks speak for themselves: Prism is 2.56 times faster when parsing to C structs compared to parse.y and twelve times faster than the parser gem for AST walks. For developers, this means faster IDE responses and shorter CI times. In case of compatibility issues, the classic parser can still be activated:

ruby --parser=parse.y script.rb

For most projects, however, Prism should work flawlessly.

If you want to run Ruby 4.0 in parallel with older versions, you need a version manager. These tools solve a fundamental problem: each Ruby project may require a different Ruby version, and gems are not compatible between Ruby versions.

Version managers install multiple Ruby versions in isolation from each other – typically under ~/.rvm, ~/.asdf, or ~/.local/share/mise. Each Ruby version gets its own directory with its own gem folder. So, if you run gem install rails under Ruby 3.3, Rails will end up in a different directory than under Ruby 4.0. Gems must therefore be installed separately for each Ruby version. Bundler (bundle install) does this automatically based on the Gemfile.

Which Ruby version applies to a project is determined by a file in the project directory: .ruby-version (simple default) or .tool-versions (for asdf and mise, can also define Node, Python, etc.). When you navigate to the project directory, the version manager automatically activates the correct Ruby version.

The first popular Ruby version manager was RVM. It profoundly modifies the shell environment and also manages gemsets – isolated gem environments per project. This was revolutionary before Bundler (2010), as there was no other way to isolate gem dependencies per project. Today, gemsets are obsolete, as Bundler solves this task better.

asdf replaced RVM for many teams. The key advantage: one tool for all languages. Via plugins, asdf manages Ruby, Node.js, Python, Elixir, and dozens of other runtimes uniformly. The .tool-versions file in the project directory defines all required versions. asdf is less invasive than RVM, written in Bash, and integrates cleanly into the shell.

The current trend is towards mise, named after the mise en place in cooking. Developed by asdf maintainer Jeff Dickey, mise is a complete rewrite in Rust. The advantages: significantly faster (Rust instead of Bash), compatible with asdf plugins and .tool-versions files, but also with its own backends. mise activates versions without shell hooks via shims – a simple mise activate in the shell configuration is sufficient. Furthermore, mise can manage environment variables and tasks, making it a universal manager for development environments. This is how Ruby 4 is installed with mise:

mise install ruby@4.0.0
mise use ruby@4.0.0
mise activate  # einmalig in .bashrc/.zshrc

For new projects, mise is the best choice. It is fast, modern, and versatile. Existing asdf setups continue to work; mise reads their configuration. RVM users should consider switching.

The practical breaking change balance of Ruby 4.0 is moderate. Fedora evaluates: Since the soname, i.e., the identifier for shared libraries, changes with Ruby 4.0, packages with binary extensions must be rebuilt. However, since great attention was paid to source compatibility, no code changes are necessary. Further breaking changes include:

  • Binding#local_variables no longer contains numbered parameters
  • ObjectSpace._id2ref is deprecated
  • CGI-Library removed from default gems (only cgi/escape remains)
  • SortedSet removed and requires the gem sorted_set
  • String Literal Warning: In files without a frozen_string_literal comment, mutation generates a deprecation warning

The pessimistic constraint practice of many gems remains problematic: ~> 3.x in required_ruby_version prevents installation under Ruby 4.0, even if the code would run without changes.

For developers, Ruby 4.0 primarily means continuity: existing code continues to run, performance continues to improve (YJIT offers a 92 percent speedup over the interpreter), and the type system matures. The real innovation lies in the infrastructure for the next decade – ZJIT, Modular GC, and the improved Ractors will keep Ruby competitive for years to come.

(ulw)

Don't miss any news – follow us on Facebook, LinkedIn or Mastodon.

This article was originally published in German. It was translated with technical assistance and editorially reviewed before publication.