Ruby 4.0 - a big update for its 30th birthday
Ruby is turning 30. It's the age when you stop experimenting with weird hairstyles and start investing in comfortable shoes and decent coffee. The release of Ruby 4.0 is just such a moment of maturity. Instead of turning everything upside down with another revolution, the core team focused on what engineers like best: ergonomics, cleaning up technical debt, and performance that doesn't require us to rewrite half of the application. If you work with Rails on a daily basis, these changes may seem subtle, but in reality, they are the foundation for stable systems for the next decade. Let's go through what will actually change in your code.
Syntax that finally makes sense
Let's start with something that has been annoying pure code purists for years. It's about line breaks in long logical conditions. Previously, the Ruby parser was a bit confused if you moved a logical operator to a new line. This forced us to leave 'hanging' operators at the end of the line, which made it difficult to quickly scan the business logic. In version 4.0, the parser has finally caught up to the standards we know from other modern languages.
We can now write code in the 'leading operator' style. This is a purely cosmetic change, but it drastically improves readability in places such as complex access policies or conditions in models. Imagine a situation where you need to check several flags on a user. Now it looks as clean as it should.
def promote?
# The parser now understands that the logic continues
# because the operator starts the new line.
user.active?
&& user.tenure > 1.year
&& !user.on_probation?
&& (user.region == "EU" || user.super_admin?)
endThis approach has another hidden advantage. If you are debugging this code, you can simply comment out one line (e.g. region check) without having to edit the preceding line to remove the dangling operator. It's a small quality of life victory that you will appreciate during your first major refactoring.
Fewer requires, faster start-up
Another change is a nod to performance and clean start-up files. For years, in order to use the Set or Pathname classes, we had to remember to explicitly import the appropriate libraries. This was a relic of a time when the goal was to keep the core of the language as small as possible. In Ruby 4.0, however, it was decided that in 2025, operations on sets and file paths are so fundamental that they should be available 'out of the box'.
The Set and Pathname classes have been added to Core. What's more, Set has been rewritten from pure Ruby to native C code. This means that set operations are now much faster, and your application doesn't waste CPU cycles loading external files at startup. You can forget about require “set” in your initialisers.
# No need to require 'set' anymore. It is just there.
class TagManager
# Using Set is now faster due to C implementation
ALLOWED_TAGS = Set.new(["ruby", "rails", "performance", "architecture"])
def valid_tag?(tag)
ALLOWED_TAGS.include?(tag)
end
endThe same applies to Pathname. If you write administrative scripts or operate on files in Active Storage, the code becomes lighter and more direct. It's a minor clean-up of the standard library that eliminates unnecessary boilerplate.
Ractor matures
Concurrency in Ruby has always been a hot topic of debate. The Global Interpreter Lock (GIL) was our old friend and enemy at the same time. Ruby 3.0 introduced Ractors to the world – an experimental concurrency model based on share-nothing architecture. In version 4.0, Ractors are entering a phase of stabilisation. The key new feature here is the Ractor::Port mechanism, which replaces the older yield/take-based approach.
For backend engineers, this means that we are approaching a point where heavy background calculations can be performed in parallel within a single Ruby process without the risk of race conditions. The new port interface is somewhat reminiscent of the channels known from the Go language, which makes communication between the main thread and workers more intuitive.
# Experimental: Using Ractor ports for background processing
# This runs in parallel, utilizing multiple CPU cores properly.
port = Ractor::Port.new
worker = Ractor.new(port) do |p|
# Receive data from the main thread
data_array = p.receive
# Perform heavy calculation
result = data_array.map { |x| x ** 2 }
# Send result back
p.send(result)
end
# Send work to the Ractor
port.send([1, 2, 3, 4, 5])
# Retrieve the result without blocking the entire VM logic
puts "Result: #{port.receive}"Although Reactors may still require caution in production code, the direction is clear. Ruby wants to be fast not only in handling HTTP requests, but also in data processing, challenging compiled languages in specific applications.
Hidden performance gems
Under the hood of Ruby 4.0, there is even more magic going on that is not immediately apparent. One of the most interesting optimisations is the handling of *nil in method definitions. This is a mechanism for those who fight for every microsecond and byte of memory. It allows you to explicitly declare that a method does not take positional arguments, which instructs the interpreter not to allocate even an empty array for args.
On the scale of a single call, this is nothing. On the scale of a framework such as Rails, which processes thousands of requests and renders millions of views, these savings add up. On top of that, there is ZJIT (a new JIT compiler) and faster object allocation by Class#new.
# app/controllers/api/base_controller.rb
# Utilizing *nil optimization
def render_success(*nil, **options)
# *nil hints the VM: "I strictly do not accept positional args"
# This avoids allocating an empty array [] internally.
render json: { status: "ok" }.merge(options)
endThanks to these changes, simply updating the Ruby version in the .ruby-version file and rebuilding the Docker image can give your application a noticeable performance boost without touching a single line of business code.
No more command injection
Finally, it is worth mentioning security. Ruby 4.0 finally breaks with the dangerous past of the Kernel#open method. For years, this method was like a Swiss Army knife – it opened files, but if the string started with |, it launched a system process. This was the cause of countless Command Injection vulnerabilities.
Now this behaviour has been removed. If you want to run a process, you have to do so explicitly. This is a breaking change, but necessary for the health of the ecosystem.
# This will now raise ArgumentError in Ruby 4.0
open("| rm -rf /")
# You must be explicit about your intentions:
# Use IO.popen for commands:
IO.popen("ls -la").read
# Use File.open for files:
File.open("user_data.txt")Summary
Ruby 4.0 is proof that the language has matured. It no longer tries to impress anyone with fireworks, but provides solid, secure and fast tools for professionals. It's the evolution we've all been waiting for, even if we didn't all realise it.
Happy rubying!