DEV Community

Koichi Sasada
Koichi Sasada

Posted on

Reading Ruby 4.0 NEWS with Pros

(This article is AI translation version of https://product.st.inc/entry/2025/12/25/134932 from Japanese to English)

We are Koichi Sasada (ko1) and Yusuke Endoh (mame) from STORES, Inc. We develop Ruby (MRI: Matz Ruby Implementation, the so-called ruby command). We are paid to develop Ruby, so we are professional Ruby committers.

Today, 12/25, Ruby 4.0.0 was released as the annual Christmas release (Ruby 4.0.0 Released | Ruby). This year as well, we will explain the Ruby 4.0 NEWS.md file on STORES Product Blog (by the way, this is an article for STORES Advent Calendar 2025. Please read the others too). For what a NEWS file is, see the previous articles (all articles in Japanese).

This article not only explains new features but also, within the limits of our memory, includes background and struggles behind the changes.

Ruby's first release was announced in 1995, so this year marks the 30th anniversary.
That is why we have a commemorative Ruby 4.0.0 release.

Representative changes in Ruby 4.0 are as follows (excerpted from the release notes).

  • (Language spec) Logical binary operators can be placed at the beginning of a line
  • Experimental introduction of Ruby Box
  • Experimental introduction of ZJIT
  • Ractor improvements (still experimental)

(Mostly experimental, huh?)

In this article, we will roughly introduce items in the NEWS file, including these.

Language changes

When calling foo(*nil), it no longer calls nil.to_a

  • *nil no longer calls nil.to_a, similar to how **nil does not call nil.to_hash. [Feature #21047]

I didn't know this, but foo(*nil) used to call nil.to_a. And since that returns [], it ultimately means there are no arguments.

In Ruby 3.3, foo(**nil) was introduced and it does not call nil.to_hash, so a proposal was made that nil.to_a should likewise not be called. It was accepted. This removes one method call and the creation of one empty array, which should be good for performance.

p nil.to_a #=> []

def nil.to_a
  p :to_a
  []
end
p(*nil)
# Ruby 3.4
# => :to_a
# Ruby 4.0
# => nothing is printed
Enter fullscreen mode Exit fullscreen mode

It's a compatibility break if you want to call it that, but I want to believe there is no code that depends on it.

(ko1)

Line breaks before logical operators are now allowed

  • Logical binary operators (||, &&, and and or) at the beginning of a line continue the previous line, like fluent dot. [Feature #20925]

When an if condition spans multiple lines, it can be hard to tell the condition from the body.

if cond1 &&
  cond2 # part of the condition
  body1 # first statement inside the if
  body2
end
Enter fullscreen mode Exit fullscreen mode

In this code, cond2 is part of the condition and body1 is the first statement in the if body, but the indentation is the same, so it's hard to distinguish.

There were tricks to make it clearer, like indenting cond2 more deeply, inserting a blank line between cond2 and body1, or inserting then, but all of them felt awkward.

# tried using then
if cond1 &&
  cond2
then
  body1
  body2
end
Enter fullscreen mode Exit fullscreen mode

So in Ruby 4.0.0, you can place a line break before && and ||.

if cond1
  && cond2
  body1
  body2
end
Enter fullscreen mode Exit fullscreen mode

With && cond2, it's obvious that it's part of the condition, so the problem is solved.

...Or is it? It feels like we just added one more awkward style, though it might be convenient once you get used to it.

Backstory

The discussion around this change got pretty lively.

In Ruby, whether an expression continues to the next line can generally be determined by the end of the previous line, but the method-call dot was an exception.

ary                       # can be a complete expression here
  .map { it.to_s }        # next line starts with a dot, so it continues as one expression
  .select { it.size > 3 } # convenient for multi-line method chaining
Enter fullscreen mode Exit fullscreen mode

ary is a complete expression on its own, but because the next line starts with .map, the expression continues. This change extends that exception to &&, ||, and, and or.

But if we expand this exception, won't people want to break lines before other binary operators too? For example, breaking before + in x + y? But that would be incompatible (+ y can be parsed as the unary +). Is it worth the risk of making Ruby's line-break rules harder to understand just to allow breaks before &&?

Some people felt that resistance, but in the end Matz said "do it", so we did.

As an aside, personally I thought it would look cool to write && cond2 without indentation, aligned with if cond1.

if cond1
&& cond2
  body1
  body2
end
Enter fullscreen mode Exit fullscreen mode

But there are basically no supporters of this style (at Ruby developer meetings it was dismissed as "not worth discussing"), which is sad.

(mame)

Built-in class updates

Array#rfind was added

  • Array#rfind has been added as a more efficient alternative to array.reverse_each.find [Feature #21678]
  • Array#find has been added as a more efficient override of Enumerable#find [Feature #21678]

Array#rfind was introduced to find the last element that matches a condition. It iterates in reverse from the last element and returns the first element that matches the condition.

# returns 4 instead of 2
[1, 2, 3, 4, 5].rfind { it.even? } #=> 4
Enter fullscreen mode Exit fullscreen mode

Also, Array#find was added. There was already Enumerable#find, but defining a method specialized for Array makes it faster.

(mame)

Binding#implicit_parameters was added

  • Binding#implicit_parameters, Binding#implicit_parameter_get, and Binding#implicit_parameter_defined? have been added to access numbered parameters and "it" parameter. [Bug #21049]

A metaprogramming API to read numbered parameters and the implicit it from Binding was added.

["foo"].each do
  p it #=> "foo"

  # read the implicit argument it from the binding
  p binding.implicit_parameters #=> [:it]
  p binding.implicit_parameter_get(:it) #=> "foo"
end

{ foo: 42 }.each do
  p _1 #=> :foo
  p _2 #=> 42

  # read implicit arguments _1 and _2 from the binding
  p binding.implicit_parameters #=> [:_1, :_2]
  p binding.implicit_parameter_get(:_1) #=> :foo
  p binding.implicit_parameter_get(:_2) #=> 42
end
Enter fullscreen mode Exit fullscreen mode

This was introduced to display _1 or it in the debugger, so normally you don't need to use it. Please don't.

  • Binding#local_variables does no longer include numbered parameters. Also, Binding#local_variable_get, Binding#local_variable_set, and Binding#local_variable_defined? reject to handle numbered parameters. [Bug #21049]

Until Ruby 3.4, you could read numbered parameters with binding.local_variable_get(:_1), but in Ruby 4.0 it raises NameError, so be careful.

The reason for this change is as follows. The implicit it introduced in Ruby 3.4 can also be used as a normal local variable name, so binding.local_variable_get(:it) becomes ambiguous. Along with banning it, _1 was also banned.

Since numbered parameters and implicit it have different scope rules from ordinary local variables, it's arguably not good to handle them together in Binding#local_variables, and they are now handled by a separate method, implicit_parameters.

(mame)

Enumerator.produce now accepts the size keyword

  • Enumerator.produce now accepts an optional size keyword argument to specify the size of the enumerator. It can be an integer, Float::INFINITY, a callable object (such as a lambda), or nil to indicate unknown size. When not specified, the size defaults to Float::INFINITY. [Feature #21701]

A size keyword was added to Enumerator.produce.

Enumerator.produce basically creates an infinite Enumerator.

# Enumerator equivalent to (1..)
enum = Enumerator.produce(1) {|n| n + 1 }

p enum.take(5) #=> [1, 2, 3, 4, 5]
p enum.size    #=> Float::INFINITY
Enter fullscreen mode Exit fullscreen mode

A less-known trick is that you can stop produce by raising StopIteration.

# Enumerator equivalent to (1..10)
enum = Enumerator.produce(1) do |n|
  raise StopIteration if n > 10
  n + 1
end

p enum.to_a #=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
p enum.size #=> Float::INFINITY (?)
Enter fullscreen mode Exit fullscreen mode

In that case, having enum.size be Float::INFINITY is not great. So now you can explicitly specify the length with the size keyword.

# Enumerator equivalent to (1..10)
enum = Enumerator.produce(1, size: 10) do |n|
  raise StopIteration if n > 10
  n + 1
end

p enum.to_a #=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
p enum.size #=> 10
Enter fullscreen mode Exit fullscreen mode

However, unless you specify this size keyword, the Enumerator returned by produce will still claim its size is Float::INFINITY, and if you specify a lie in the size keyword, it will lie as well. In short, it's best not to trust Enumerator#size too much.

(mame)

error_highlight now shows ArgumentError more helpfully

  • When an ArgumentError is raised, it now displays code snippets for both the method call (caller) and the method definition (callee). [Feature #21543]

Consider the following code.

def add(x, y) = x + y

add(1)
Enter fullscreen mode Exit fullscreen mode

The number of arguments to add is wrong, so an error is raised. Now it shows code snippets for both the caller and the callee.

# In Ruby 4.0
$ ruby test.rb
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
          ^^^
        from test.rb:3:in '<main>'
Enter fullscreen mode Exit fullscreen mode

In Ruby 3.4, it was a simple display like this.

# In Ruby 4.0
$ ruby test.rb
test.rb:1:in 'add': wrong number of arguments (given 1, expected 2) (ArgumentError)
        from test.rb:3:in '<main>'
Enter fullscreen mode Exit fullscreen mode

When you see this error, people reflexively look at the source of test.rb:1. But when you get "wrong number of arguments", you almost never need to fix the method definition side. You end up looking at the error again and reopening the source for test.rb:3.

We felt repeating this work was wasteful, so after proposing and discussing a stack trace improvement, we decided to display it as shown above using error_highlight.

(mame)

Changes around Fiber::Scheduler

  • Introduce Fiber::Scheduler#fiber_interrupt to interrupt a fiber with a given exception. The initial use case is to interrupt a fiber that is waiting on a blocking IO operation when the IO operation is closed. [Feature #21166]

A method called Fiber::Scheduler#fiber_interrupt was added.
The story gets fairly specific, but here is the usage scenario.

  1. When thread A is doing io.read on some io and thread B tries to io.close, A can end up waiting forever (depending on the OS), so io.close raises an exception for the list of threads blocked on that (in this case thread A).
  2. There was a request to do the same thing in a fiber scheduler, so Fiber::Scheduler#fiber_interrupt was introduced to make that possible.

I don't know the actual code well enough to write it...

  • Introduce Fiber::Scheduler#yield to allow the fiber scheduler to continue processing when signal exceptions are disabled. [Bug #21633]

Fiber::Scheduler#yield was introduced (when did that happen...).

After receiving a signal, if it cannot be handled in the normal flow, it schedules other schedulable fibers. I wonder if that's okay.

  • Reintroduce the Fiber::Scheduler#io_close hook for asynchronous IO#close.

Fiber::Scheduler#io_close was reintroduced. It says so, but there's no reference, so I have no idea.

  • Invoke Fiber::Scheduler#io_write when flushing the IO write buffer. [Bug #21789]

When you do IO#close or IO#fush, data accumulated in the write buffer is output to the file. Previously this happened without considering the fiber scheduler, but now it is done via the io_write hook for the fiber scheduler.

(ko1)

File::Stat#birthtime is now available on Linux

  • File::Stat#birthtime is now available on Linux via the statx system call when supported by the kernel and filesystem. [Feature #21205]

It seems you can now get file creation time on Linux.

This is only available when the statx(2) system call is available and the filesystem records creation time, but in modern Linux it should usually work (though please research the exact conditions yourself).

(mame)

IO.select can now accept Float::INFINITY as a timeout value

  • IO.select accepts Float::INFINITY as a timeout argument. [Feature #20610]

IO.select is an API for waiting until multiple I/Os are ready (e.g., read won't block). It accepts a timeout, and with nil (or omitted) it waited indefinitely. This change allows you to specify Float::INFINITY as that infinite timeout.

(ko1)

You can now choose which instance variables are shown in inspect

  • Kernel#inspect now checks for the existence of a #instance_variables_to_inspect method, allowing control over which instance variables are displayed in the #inspect string [Feature #21219]

I think you've had the experience of casually p-ing an object and being startled by a huge output.

class Foo
  def initialize(id)
    @id = id
    @internal_data = "a" * 1000
  end
end

p Foo.new("foo")
#=> #<Foo:0x00007c495d699dd0 @internal_data="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
Enter fullscreen mode Exit fullscreen mode

If you want @id shown but not @internal_data, a feature was added that lets you specify which instance variables are displayed. By adding an instance_variables_to_inspect method like this, only @id will be shown.

class Foo
  def initialize(id)
    @id = id
    @internal_data = "a" * 1000
  end

  # list instance variable names you want displayed as symbols
  private def instance_variables_to_inspect = [:@id]
end

p Foo.new("foo")
#=> #<Foo:0x00007692c1a44f78 @id="foo">
Enter fullscreen mode Exit fullscreen mode

It's nice and clean.

You can also use it to exclude things you don't want casually logged, like password strings (this may be the more important use case). For example, to exclude @password, you can do the following.

class LoginInfo
  def initialize(user)
    @user = user
    @password = "secret"
  end

  # remove instance variable names you don't want displayed from Kernel#instance_variable
  private def instance_variables_to_inspect = instance_variables - [:@password]
end

# @password is not shown
p LoginInfo.new("mame") #=> #<LoginInfo:0x000072b3b2426390 @user="mame">
Enter fullscreen mode Exit fullscreen mode

Mechanically, when Kernel#inspect stringifies an object, it no longer displays all instance variables unconditionally; it now calls instance_variables_to_inspect to determine which instance variables should be shown.

By the way, pp has long had a similar mechanism called pretty_print_instance_variables. This was introduced for p as well, but it would be awkward to introduce it with that name, so it became instance_variables_to_inspect. As usual, the hardest part was getting the method name to settle (name discussions are very important in Ruby).

(mame)

You can no longer start a process with open("| ls")

  • Kernel
    • A deprecated behavior, process creation by Kernel#open with a leading |, was removed. [Feature #19630]
  • IO
    • A deprecated behavior, process creation by IO class methods with a leading |, was removed. [Feature #19630]

Ruby's Kernel#open is a method to open files, but if you pass it a string starting with a pipe symbol like "| ls", it could start a process and treat its standard I/O as a file.

# runs ls /home and prints its output via read (up to Ruby 3.4)
open("| ls /home") { puts f.read }
  #=> Ruby 3.4: mame
  #=> Ruby 4.0: No such file or directory @ rb_sysopen - | ls /home (Errno::ENOENT)
Enter fullscreen mode Exit fullscreen mode

Many users didn't know about this feature and it tended to lead to vulnerabilities, so it was deprecated two years ago in Ruby 3.3 and now finally removed.

There is no drop-in replacement. If you intentionally used this feature, please rewrite appropriately using IO.popen, etc. In most cases, replacing open("|command") with IO.popen("command") should work (but it is a dangerous feature, so please consider carefully).

(mame)

Math.log1p and Math.expm1 were added

Math.log1p(x) returns the value of $\log(x + 1)$.

You might wonder, "Why does this exist? Isn't it enough to write Math.log(x + 1.0)?" But apparently it is needed to avoid floating-point errors.

When x is very small (smaller than Float::EPSILON), x + 1.0 is rounded to 1.0, so Math.log(x + 1.0) always returns 0.

Math.log1p(x) works even for such small x.

# very small x
x = 1.0e-16

p Math.log(x + 1) #=> 0.0
p Math.log1p(x)   #=> 1.0e-16
Enter fullscreen mode Exit fullscreen mode

Well, $\log(x)$ is approximately $x$ near $x = 1$, so it's essentially just returning the argument.

Math.expm1 returns $\exp(x) - 1$, and the need is basically the same.

The proposal started when I was implementing a numerical analysis paper as a hobby and noticed there was no log1p. I was surprised no one had noticed that a function provided by C since the C99 era was missing.

During the discussion for introduction, someone quipped "log1p and expm1 are meaningless names", but I found that Python, Java, JavaScript, Go, PHP, .NET, R, MATLAB, Kotlin, Swift, Julia, Haskell, OCaml, and Crystal all use this name (Rust uses the even more cryptic ln_1p). So we decided to keep the names as-is.

(mame)

Pathname became a core class

  • Pathname has been promoted from a default gem to a core class of Ruby. [Feature #17473]

The Pathname class is now built-in. You can use Pathname without writing require "pathname".

However, Rails already does require "pathname" by default, so for most users this likely changes little. For people who often write throwaway scripts or one-liners in Ruby, it might be a small but nice improvement.

Note that you still need require "pathname" to use these three methods.

  • Pathname#find
  • Pathname#rmtree
  • Pathname#mktmpdir

This is because these methods depend on other libraries. It would be too much to make find, fileutils, and tmpdir built-ins just to make Pathname built-in, so these were not included.

As an aside, a very strange bug occurred during making Pathname built-in. I explained the debugging process in detail in another article, so if you're interested, please take a look.

[https://product.st.inc/entry/2025/07/17/104509:embed:cite]

(mame)

The implicit it return value in Proc#parameters changed slightly

  • Proc#parameters now shows anonymous optional parameters as [:opt] instead of [:opt, nil], making the output consistent with when the anonymous parameter is required. [Bug #20974]

Proc#parameters is a method to inspect what arguments a block takes.

p proc{}.parameters #=> []
p proc{|a|}.parameters #=> [[:opt, :a]]
p proc{|a, b|}.parameters #=> [[:opt, :a], [:opt, :b]]
p proc{|a, b, c=1|}.parameters #=> [[:opt, :a], [:opt, :b], [:opt, :c]]
p proc{|a, b, c=1, *d|}.parameters #=> [[:opt, :a], [:opt, :b], [:opt, :c], [:rest, :d]]
p proc{|a, b, c=1, *d, e|}.parameters #=> [[:opt, :a], [:opt, :b], [:opt, :c], [:rest, :d], [:opt, :e]]

p lambda{}.parameters #=> []
p lambda{|a|}.parameters #=> [[:req, :a]]
p lambda{|a, b|}.parameters #=> [[:req, :a], [:req, :b]]
p lambda{|a, b, c=1|}.parameters #=> [[:req, :a], [:req, :b], [:req, :c]]
p lambda{|a, b, c=1, *d|}.parameters #=> [[:req, :a], [:req, :b], [:req, :c], [:rest, :d]]
p lambda{|a, b, c=1, *d, e|}.parameters #=> [[:req, :a], [:req, :b], [:req, :c], [:rest, :d], [:req, :e]]
Enter fullscreen mode Exit fullscreen mode

All the proc{|a|} cases use [:opt, :a] (i.e., optional arguments) because block arguments can be passed or omitted. With lambda{|a|}, you must pass one argument or you'll get an error, so it becomes [:req, :a].

Now, blocks using it introduced in Ruby 3.4 are like proc{|a|} in that they receive one argument, which means one optional argument. However, you can also explicitly name an argument it (e.g., proc{|it|p it}.parameters #=> [[:opt, :it]]), so returning it as a name is ambiguous. That's why we previously used nil for the name.

But for lambda, the parameter is required, and the name-less form is just [:req]. So we decided to make them consistent and use [:opt].

p lambda{p it}.parameters
#=> [[:req]]

p proc{p it}.parameters
#=> Ruby 3.4: [[:opt, nil]]
#=> Ruby 4.0: [[:opt]]
Enter fullscreen mode Exit fullscreen mode

By the way, for numbered parameters, the variable names like :_1 still appear as before. Right now _1 cannot be used as a normal parameter name (or variable name), so it's not confusing.

p proc{p [_1, _2]}.parameters
pt, :_1], [:opt, :_2]]

p lambda{p [_1, _2]}.parameters
#=> [[:req, :_1], [:req, :_2]]
Enter fullscreen mode Exit fullscreen mode

(ko1)

Ractor::Port was introduced to reorganize Ractor communication

  • Ractor::Port class was added for a new synchronization mechanism to communicate between Ractors. [Feature #21262] Ractor::Port provides the following methods:
    • Ractor::Port#receive
    • Ractor::Port#send (or Ractor::Port#<<)
    • Ractor::Port#close
    • Ractor::Port#close As result, Ractor.yield and Ractor#take were removed.
  • Ractor#join and Ractor#value were added to wait for the termination of a Ractor. These are similar to Thread#join and Thread#value.
  • Ractor#monitor and Ractor#unmonitor were added as low-level interfaces used internally to implement Ractor#join.
  • Ractor.select now only accepts Ractors and Ports. If Ractors are given, it returns when a Ractor terminates.
  • Ractor#default_port was added. Each Ractor has a default port, which is used by Ractor.send, Ractor.receive.
  • Ractor#close_incoming and Ractor#close_outgoing were removed.

Ractor communication was reorganized around a new thing called Ractor::Port. This is summarized in the following article, so please read it for details.

https://dev.to/ko1/ractorport-revamping-the-ractor-api-98

Here's an example from NEWS. You can now write it like this.

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
Enter fullscreen mode Exit fullscreen mode

Also, to wait for a Ractor to finish, you should use join or value.

r = Ractor.new{ task }
r.join    # wait for task to finish
p r.value # wait for task to finish and get its return value; basically the same as threads
Enter fullscreen mode Exit fullscreen mode

The removal of Ractor#take, which was used for waiting, is a big change (Ractor itself is an experimental feature, so it can change more easily). It brings the API closer to threads.

(ko1)

Introducing Ractor.shareable_proc to create Proc objects shareable between Ractors

To make a Proc shareable, there are some constraints. The strictest constraint is that self while executing the block must be shareable (other constraints below). Previously, by using Ractor.make_shareable(proc_obj), you could make proc_obj shareable only when self was shareable and the other constraints were satisfied.

pr = proc{}
Ractor.make_shareable(pr)
#=> 'Racr.make_shareable': Proc's self is not shareable: #<Proc:...> (Ractor::IsolationError)
# because main is not shareable
Enter fullscreen mode Exit fullscreen mode

Making self shareable was annoying and required doing something like nil.instance_exec{ proc{ ... } }, which was uncool. To solve that, Ractor.shareable_proc was introduced (and shareable_lambda creates a lambda). This method returns the given block as a shareable Proc.

pr = Ractor.shareable_proc{ }
p Ractor.shareable?(pr) #=> true
Enter fullscreen mode Exit fullscreen mode

In this case, if you don't specify anything, self becomes nil. To change it, use the self: keyword.

pr = Ractor.shareable_proc(self: Ractor){
  self
}
p pr.call #=> Ractor
Enter fullscreen mode Exit fullscreen mode

In most cases it will be used without self:, so cases where you must specify self: should be rare. Procs shared between Ractors are likely to be used to pass tasks, for example.

worker = Ractor.new do
  while task = Ractor.receive
    task.call
  end
end

def heavy_task = sleep(3)

# assign three heavy_task runs to the Ractor
worker << Ractor.shareable_proc{ heavy_task }
worker << Ractor.shareable_proc{ heavy_task }
worker << Ractor.shareable_proc{ heavy_task }
Enter fullscreen mode Exit fullscreen mode

So if you felt that it was hard to make shareable Procs, this API solves that concern.

Supplement: Conditions for a Proc to become shareable:

  1. (dynamic) self is shareable
  2. (static) it does not write to outer local variables
  3. (dynamic) when reading outer local variables, those locals contain shareable objects. The list of outer locals read is determined statically
  4. (static) when reading outer local variables, those locals are not reassigned

When I say static and dynamic here, static means checks done at compile time, and dynamic means checks done at runtime (when the Proc is created). Note that if you eval inside a shareable Proc, it checks that these conditions are satisfied.

class C
  a = 1
  pr = proc{a = 2}
  Ractor.make_shareable(pr)
  #=> 'Ractor.make_shareable': can not make a Proc shareable because it accesses outer variables (a). (ArgumentError)
  # error because it writes to outer local variable a
end
Enter fullscreen mode Exit fullscreen mode

Condition 4 is tricky, so here is more explanation.

a = 1
pr = Ractor.shareable_proc(self: Ractor){
  p a
}
#=> cannot make a shareable Proc because the outer variable 'a' may be reassigned. (Ractor::IsolationError)
a = 2
Enter fullscreen mode Exit fullscreen mode

In this case, a referenced by the Proc is reassigned outside the block. This check is needed to keep behavior as close as possible to a "normal Proc". A shareable Proc records the value of the variable at the time it is created, in this case 1 for a. So even if you do a = 2 outside, a inside the shareable Proc will always return 1. This differs from the behavior of a normal Proc, so starting in Ruby 4.0 such cases are now errors.

(ko1)

Range#to_set now checks size

  • Range#to_set now performs size checks to prevent issues with endless ranges. [Bug #21654]

When you do (0..).to_set, Ruby 3.4 tried to create an infinite-size Set and hung, but Ruby 4.0 now raises an exception.

(0..).to_set
  #=> Ruby 3.4: never ends until it eats all memory
  #=> Ruby 4.0: cannot convert endless range to a set (RangeError)
Enter fullscreen mode Exit fullscreen mode

(mame)

The fine-grained behavior of Range#overlap? changed

  • Range#overlap? now correctly handles infinite (unbounded) ranges. [Bug #21185]

The return value of (..3).overlap?(nil..nil) changed from false to true.

(mame)

The fine-grained behavior of Range#max changed

(..10).max(2) now returns [10, 9]. (1..10).max(2) has long returned [10, 9], so this aligns it.

That said, (1.0 .. 10).max(2) raises an exception, which feels a bit unclear to me personally.

(mame)

::Ruby was defined

  • A new toplevel module as been defined, which contains Ruby-related constants. This module was reserved in Ruby 3.4 and is now officially defined. [Feature #20884]

The ::Ruby module was defined (in Ruby 3.4, you would get a warning if you tried to define a top-level Ruby constant). What exists now is that constants like RUBY_VERSION are defined there as well. Constants like RUBY_VERSION still remain as they were.

p Ruby::VERSION #=> "4.0.0"
p RUBY_VERSION #=> same as "4.0.0"

pp Ruby.constants
#=>
[:REVISION,
 :COPYRIGHT,
 :ENGINE,
 :ENGINE_VERSION,
 :VERSION,
 :RELEASE_DATE,
 :DESCRIPTION,
 :PLATFORM,
 :PATCHLEVEL]
Enter fullscreen mode Exit fullscreen mode

Will there be more and more Ruby::... in the future?

(ko1)

Ruby::Box was introduced

Ruby 4.0.0's headline feature, Ruby::Box, was introduced experimentally.

We wrote a dedicated article about it, so please see that.

https://dev.to/ko1/rubybox-digest-introduction-ruby-400-new-feature-3bch

(mame)

Set became a core class

  • Set is now a core class, instead of an autoloaded stdlib class. [Feature #21216]

Set is now a core class. It was rewritten in C, which makes some methods faster, and its compatibility with other built-in features has subtly improved. Since Set already had autoload set up, visible behavior changes for users are likely small, probably, hopefully.

  • Set#inspect now returns a string suitable for eval, using the Set[] syntax (e.g., Set[1, 2, 3] instead of #<Set: {1, 2, 3}>). This makes it consistent with other core collection classes like Array and Hash. [Feature #21389]

The clearest change is the Set#inspect result.

p Set[1, 2, 3]
  #=> Ruby 3.4: #<Set: {1, 2, 3}>
  #=> Ruby 4.0: Set[1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

Array and Hash show their literal forms, so Set now also displays using the normal literal-like syntax for creating it.

  • Passing arguments to Set#to_set and Enumerable#to_set is now deprecated. [Feature #21390]

A subtle incompatibility is that arguments to to_set were removed. This was a feature where you could pass a subclass of Set as an argument to specify the class of the generated object.

class MySet < Setend

p (1..3).to_set        #=> #<Set: {1, 2, 3}>
p (1..3).to_set(MySet) #=> #<MySet: {1, 2, 3}>
Enter fullscreen mode Exit fullscreen mode

Personally, I think you shouldn't inherit from core classes.

(mame)

You can now specify connection timeouts for Socket.tcp and TCPSocket.new

  • Socket.tcp & TCPSocket.new accepts an open_timeout keyword argument to specify the timeout for the initial connection. [Feature #21347]

Socket.tcp and TCPSocket.new would block until the OS-defined timeout if a connection could not be made for some reason.

This timeout is about 127 seconds on Linux by default.

# raises after blocking for about 127 seconds (203.0.113.1 is a test address)
p Socket.tcp("203.0.113.1", 80)
  #=> Connection timed out - connect(2) for 203.0.113.1:80 (Errno::ETIMEDOUT)
Enter fullscreen mode Exit fullscreen mode

This is very slow, so in practical applications it was essentially mandatory to wrap the connection with Timeout.timeout.

In Ruby 4.0, you can specify the timeout by passing the open_timeout keyword argument to Socket.tcp and TCPServer.new.

# raises after blocking for 1 second
p Socket.tcp("203.0.113.1", 80, open_timeout: 1)
  #=> Connection timed out - user specified timeout for 203.0.113.1:80 (Errno::ETIMEDOUT)
Enter fullscreen mode Exit fullscreen mode

There were already resolv_timeout and connect_timeout keyword arguments for name resolution and connection timeouts. The difference is that open_timeout specifies the overall timeout from name resolution through connection.

With Happy Eyeballs v2 introduced in Ruby 3.4, name resolution and connection are partially parallelized, which made it harder to understand, so the more convenient overall timeout open_timeout was introduced.

It seems net-http quickly switched to using open_timeout (when available) instead of Timeout.timeout.

Completely as an aside, Linux's default of 127 seconds is because it starts at 1 second and performs exponential backoff retransmissions six times (1 + 2 + 4 + 8 + 16 + 32 + 64 = 127). You can change the retry count by changing net.ipv4.syn_retries = 6.

(mame)

Exceptions raised by Socket.tcp and TCPSocket.new changed

  • When a user-specified timeout occurred in TCPSocket.new, either Errno::ETIMEDOUT or IO::TimeoutError could previously be raised depending on the situation. This behavior has been unified so that IO::TimeoutError is now consistently raised. (Please note that, in Socket.tcp, there are still cases where o::ETIMEDOUT may be raised in similar situations, and that in both cases Errno::ETIMEDOUT may be raised when the timeout occurs at the OS level.)

To get to the conclusion first: these methods may raise Errno::ETIMEDOUT or IO::TimeoutError on timeout, so if you rescue, please rescue both.

begin
  Socket.tcp(...)
rescue Errno::ETIMEDOUT, IO::TimeoutError
end
Enter fullscreen mode Exit fullscreen mode

Errno::E* follows the principle of system call failures. Following that principle, when the connect system call fails due to a timeout, Errno::ETIMEDOUT is raised. On the other hand, the timeout specified by the user with open_timeout is not a system call failure, so IO::TimeoutError is raised.

Previously, user-specified timeouts sometimes raised Errno::ETIMEDOUT as well, but it seems it was aligned to IO::TimeoutError to follow the principle.

It is inconvenient to have to rescue both, and we'd like to do something about it, but we noticed the issue fairly close to release (it was discovered while writing this NEWS commentary article), so let's hope for the future.

(mame)

Updated to Unicode 17.0.0

  • Update Unicode to Version 17.0.0 and Emoji Version 17.0. [Feature #19908][[Feature #://bugs.ruby-lang.org/issues/20724)][Feature #21275] (also applies to Regexp)

We updated Ruby's Unicode version from 15.0.0 to 17.0.0. There are no major changes. It includes support for new characters, adding new Unicode properties, and updating the rules for counting grapheme clusters in Indic scripts.

In Ruby regexps, you can match using Unicode properties.

'a'.match?(/\p{Hiragana}/)
# => false
'あ'.match?(/\p{Hiragana}/)
# => true
Enter fullscreen mode Exit fullscreen mode

You can check what a property like Hiragana matches on this site. It seems it also matches the character 🈀. Properties can match characters that differ from your mental image, so if you need strict matching for a specific set of characters, it's better not to use them.

https://util.unicode.org/UnicodeJsps/regex.jsp?a=\p{Hiragana}&b=あa

When updating Unicode versions, we also update these properties. It's automated, so it is easy to add by just running a script.

Here is the list of Unicode properties available in Ruby.

[https://docs.ruby-lang.org/en/master/language/regexp/unicode_properties_rdoc.html:embed:cite]

Here are the Unicode release notes.

(ima1zumi)

String#strip can now take a set of characters to remove

  • String#strip, strip!, lstrip, lstrip!, rstrip, and rstrip! are extended to accept selectors arguments. [[Feature #21552]]

String#strip removes whitespace from both ends of a string, but now you can pass an optional argument specifying the set of characters to remove. By passing multiple characters, they are treated as a set of characters to remove (same as String#tr).

p ' str_!'.strip('!_')
#=> " str"
#   leading space is not removed, and trailing _ and ! are removed
Enter fullscreen mode Exit fullscreen mode

You can also specify ranges like 'x-y' (also like String#tr).

p 'str345'.strip('0-9')
#=> "str" # remove trailing 0-9 characters
Enter fullscreen mode Exit fullscreen mode

I asked if passing a string as a character set rather than a substring to remove might be confused with the behavior of String#delete_suffix (for example, 'bar'.strip('rab') removes everything), and was told, "You'll know if you try it." Dismissed.

(ko1)

Thread#raise/Fiber#raise can now take the cause: keyword

  • Thread
    • Introduce support for Thse:) argument similar to Kernel#raise. [Feature #21360]
  • Fiber
    • Introduce support for Fiber#raise(cause:) argument similar to Kernel#raise. [Feature #21360]

Like Kernel#raise(cause: exc), the cause: argument can specify another exception that caused this exception (default $!). Now you can specify cause: in Thread#raise as well, but if you do so it might lead to confusing situations (for example, sending an exception that occurred in the current thread to another thread), so use with care.

Fiber#raise(cause:) is the same story as Thread#raise(cause:).

(ko1)

Stdlib updates

We only list stdlib changes that are notable feature changes.

Other changes are listed in the following sections. We also listed release
history from the previous bundled version that is Ruby 3.4.0 if it has GitHub
releases.

6.3

The libraries above are no longer default gems. That means they can no longer be require-d without being in your Gemfile, so please be careful if you use them.

The following default gem is added.

  • win32-registry 0.1.2

The following default gems are updated.

The following bundled gems are updated.

There are a lot of updates.
I can't read them all, so I'll skip.

IRB: added the copy command

The copy command was added. It lets you copy output results to the system clipboard.

For example, if you want to copy model output in a Rails console like this, running copy after the output will copy from User:0x00.. to updated_at to the clipboard.

app(dev)> User.last
=>
#<User:0x0000000351352c40
 id: 1,
 email: "test@example.com",
 name: "Ruby",
 created_at: "2025-12-09 17:48:39.000000000 +0900",
 updated_at: "2025-12-09 17:48:39.000000000 +0900">
app(dev)> copy
Copied to system clipboard
Enter fullscreen mode Exit fullscreen mode

(ima1zumi)

(ko1 addendum: it calls the system command for copying to the clipboard (like pbcopy), so it won't work in a terminal on an SSH host, for example.)

RubyGems and Bundler

Ruby 4.RubyGems and Bundler version 4. see the following links for details.

Supported platforms

  • Windows
    • Dropped support for MSVC versions older than 14.0 (_MSC_VER 1900). This means Visual Studio 2015 or later is now required.

On Windows native, it seems you can no longer build without Visual Studio 2015 or later. Version numbers like 14.0, 1900, and 2015 for VC are complicated, with many different kinds.

(mame)

Compatibility issues

  • The following methoded from Ractor due to the addition of Ractor::Port:
    • Ractor.yield
    • Ractor#take
    • Ractor#close_incoming
    • Ractor#close_outgoging [Feature #21262]

This is also covered in the article.

[https://product.st.inc/entry/2025/06/24/110606:embed:cite]

ObjectSpace._id2ref, which gets an object from its ID (the one you get via #object_id), was deprecated. The decision to deprecate it was made long ago, but for some reason it was forgotten, so now it is finally deprecated.

id = self.object_id
p ObjectSpace._id2ref(id) #=> main
# In Ruby 4.0, you get a warning -> warning: ObjectSpace._id2ref is deprecated
Enter fullscreen mode Exit fullscreen mode
  • Process::Status#& and Process::Status#>> have been removed. They were deprecated in Ruby 3.3. [Bug #19868]

Process::Status#& and Process::Status#>> were removed. They were already deprecated in Ruby 3.3. They were used to peek at status codes, but now there are dedicated methods, so they are no longer used.

  • rb_path_check has been removed. This function was used for $SAFE path checking which was removed in Ruby 2.7, and was already deprecated,. [Feature #20971]

The rb_path_check function was removed. It depended on the $SAFE feature that was removed long ago, but it remained for compatibility, so it was removed this time.

(ko1)

Backtrace formatting changed

  • A backtrace for ArgumentError of "wrong number of arguments" now include the receiver's class or module name (e.g., in Foo#bar instead of in bar). [[Bug #21698]]

In backtraces for "wrong number of arguments", the class name now appears.

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

# Ruby 4.0
test.rb:1:in 'Object#add': wrong number of arguments (given 1, expected 2) (ArgumentError)
Enter fullscreen mode Exit fullscreen mode

The method name notation changed from add to Object#add.

  • Backtraces no longer display internal frames. These methods now appear as if it is in the Ruby source file, consistent wither C-implemented methods. [[Bug #20968]]

Recently, more built-in methods are being written in Ruby rather than C. As a result, backtraces often showed a mysterious file like <internal:???>.

# Ruby 3.4
$ ruby -e '[1].fetch_values(42)'
<internal:array>:211:in 'Array#fetch': index 42 outside of array bounds: -1...1 (IndexError)
        from <internal:array>:211:in 'block in Array#fetch_values'
        from nternal:array>:211:in 'Array#map!'
        from <internal:array>:211:in 'Array#fetch_values'
        from -e:1:in '<main>'

# Ruby 4.0
$ ruby -e '[1].fetch_values(42)'
-e:1:in 'Array#fetch_values': index 42 outside of array bounds: -1...1 (IndexError)
        from -e:1:in '<main>'
Enter fullscreen mode Exit fullscreen mode

This was a virtual file name representing the source where built-in methods are defined. However, there is nothing a normal user can do with that file name, and it just adds work to skip those frames. In most cases, what the user wants to see is the caller of the built-in method.

So we removed <internal:*> from display and changed it to show as if the error occurred at the caller.

(mame)

I think it would be better if everything was shown, though.

(ko1)

About standard library compatibility

The CGI library was removed from the bundle

  • The CGI library is removed from the default gems. Now we only provide cgi/escape for the following methods:
    • CGI.escape and CGI.unescape
    • CGI.escapeHTML and CGI.unescapeHTML
    • CGI.escapeURIComponent and CGI.unescapeURIComponent
    • CGI.escapeElement and CGI.unescapeElement [Feature #21258]

Most of the cgi gem was removed from the bundle. In modern times it's judged not to be worth the maintenance cost.

Only the eight methods above such as CGI.escape are very commonly used, so a bundle remains in the form of cgi/escape. If you don't need the full cgi gem and only need these methods, you should require "cgi/escape".

If you need the entire cgi gem, please do gem install cgi or add gem "cgi" to your Gemfile.

(mame)

SortedSet was removed from the bundle

  • With the move of Set from stdlib to core class, set/sorted_set.rb has been removed, and SortedSet is no longer an autoloaded constant. Please install the sorted_set gem and require 'sorted_set' to use SortedSet. [Feature #21287]

SortedSet was removed from the bundle. If you need it, install the sorted_set gem.

(mame)

C API updates

  • IO
    • rb_thread_fd_close is deprecated and now a no-op. If youto expose file descriptors from C extensions to Ruby code, create an IO instance using RUBY_IO_MODE_EXTERNAL and use rb_io_close(io) to close it (this also interrupts and waits for all pending operations on the IO instance). Directly closing file descriptors does not interrupt pending operations, and may lead to undefined behaviour. In other words, if two IO objects share the same file descriptor, closing one does not affect the other. [Feature #18455]

rb_thread_fd_close() was deprecated and now does nothing when called. This API used to accept a file descriptor directly, but at the Ruby level we want people to close via an IO object, so this change happened. If you want to manipulate a file descriptor directly, please use RUBY_IO_MODE_EXTERNAL when creating the IO object, and close it with rb_io_close(io).

rb_thread_call_with_gvl now works with or without the GVL.
This allows gems to avoid checking ruby_thread_has_gvl_p.
Please still be diligent about the GVL. [Feature #20750]

GVL (Global/Giant VM Lock, though it's not really that anymore since it's per-Ractor; recently we call it Great Valuable Lock) is released while doing some work, and sometimes you want to reacquire the GVL to do something (Ruby-related things require the GVL). There is an API called rb_thread_call_with_gvl() which takes a function f and calls f after acquiring the GVL. This API used to error if called when the GVL wasn't released, but now it can be called without error. There are cases where you want to call it regardless of whether the GVL is released, so it should be more convenient.

Personally, I was against it because I think you should be sensitive to GVL acquisition state, or rather avoid such shared code scenarios as much as possible, but it went in anyway. In any case, I think you should keep code that runs without the GVL as simple as possible.

  • Set
    • A C API for Set has been added. The following methods are supported: 59](https://bugs.ruby-lang.org/issues/21459)]
      • rb_set_foreach
      • rb_set_new
      • rb_set_new_capa
      • rb_set_lookup
      • rb_set_add
      • rb_set_clear
      • rb_set_delete
      • rb_set_size

Because Set became built-in, these C APIs were introduced. By the way, there are already quite a few C APIs with the rb_set_ prefix (for example rb_set_errinfo()), so there was some talk that it could be confusing.

(ko1)

Implementation improvements

  • Class#new (ex. Object.new) is faster in all cases, but especially when passing keyword arguments. This has also been integrated into YJIT and ZJIT. [Feature #21254]

Calls like Foo.new are faster. It feels like a dedicated instruction was added. By the way, I did the rough design of the instruction.

  • GC heaps of different size pools now grow independently, reducing memory usage when only some pools contain long-lived objects

GC improvements. I think this is an adjustment of VWA.

  • GC sweeping is faster on pages of large objects

I don't know what was done here.

  • "Generic ivar" objects (String, Array, TypedData, etc.) now use a new internal "fields" object for faster instance variable access

A new data structure was introduced to hold instance variables for non-user-defined objects. I wonder what it was for. Ractor?

  • The GC avoids maintainial id2ref table until it is first used, making object_id allocation and GC sweeping faster

Since the data for managing id2ref is lazily generated, calls to #object_id and sweeping are faster as long as id2ref isn't used. I wonder how they did that.

  • object_id and hash are faster on Class and Module objects

object_id and hash are faster for classes and modules. What did they do here, I wonder.

  • Largeers can remain embedded using variable width allocation

Bignum (large integers) are also faster by managing them with VWA.

  • Random, Enumerator::Product, Enumerator::Chain, Addrinfo, StringScanner, and some internal objects are now write-barrier protected, which reduces GC overhead.

They added proper write barriers to some objects so they are no longer unprotected. GC becomes faster.

(I haven't followed this very closely.

(ko1)

Ractor

A lot of work has gone into making Ractors more stable, performant, and usable. These improvements bring Ractor implementation closer to leaving experimental status.

  • Performance improvements
    • Frozen strings and the symbol table internally use a lock-free hash set [[Feature #21268]]
    • Method cache lookups avoid locking in most cases
    • Class (and generic ivar) instance variable access is faster and avoids locking
    • CPU cache contention is avoided in object allocation by using a per-ractor counter
    • CPU cache contention is avoided in xmalloc/xfree by using a thread-local counter
    • object_id avoids locking in most cases
  • Bug fixes and stability
    • Fixed possible deadlocks when combining Ractors and Threads
    • Fixed issues with require and autoload in a Ractor
    • Fixed encoding/transcoding issues across Ractors
    • Fixed race conditions in GC operations and method invalidation
    • Fixed issues with processes forking after starting a Ractor
    • GC allocation counts are now accurate under Ractors
    • Fixed TracePoints not working after GC [[Bug #19112]]

There were many performance improvements and bug fixes for Ractor. Mainly other people worked hard on it.
Although it's not written here, I think the introduction of the above Ractor::Port cleaned up functionality a lot, so that probably also improved performance.

JIT

  • ZJIT
    • Introduce an experimental method-based JITompiler. Where available, ZJIT can be enabled at runtime with the --zjit option or by calling RubyVM::ZJIT.enable. When building Ruby, Rust 1.85.0 or later is required to include ZJIT support.
    • As of Ruby 4.0.0, ZJIT is faster than the interpreter, but not yet as fast as YJIT. We encourage experimentation with ZJIT, but advise against deploying it in production for now.
    • Our goal is to make ZJIT faster than YJIT and production-ready in Ruby 4.1.

ZJIT, the successor to YJIT, was introduced, and if you have a Rust environment (rustc 1.85.0 or later) at build time, it will be built together. You can use it with the --zjit command-line argument. It's not as fast as YJIT yet, so they ask you to wait before using it in production. The goal is to exceed YJIT in Ruby 4.1.

[https://rubykaigi.org/2025/presentations/maximecb.html:embed:ciJIT
* RubyVM::YJIT.runtime_stats
* ratio_in_yjit no longer works in the default build.
Use --enable-yjit=stats on configure to enable it on --yjit-stats.
* Add invalidate_everything to default stats, which is
incremented when every code is invalidated by TracePoint.
* Add mem_size: and call_threshold: options to RubyVM::YJIT.enable.

YJIT also seems to have improvements, but I'm not familiar, so I'll skip.

  • RJIT
    • --rjit is r. We will move the implementation of the third-party JIT API to the ruby/rjit repository.

It seems the --rjit area was removed.

(ko1)

In closing

We have introduced new features and improvements in Ruby 4.0. Beyond what we covered here, there are also bug fixes and minor improvements. Please check with your Ruby applications.

With Ruby::Box, zjit, and the rebuild of Ractor, it's become a release with a lot to try. Please set it up locally and enjoy the new Ruby.

Enjoy Ruby programming!

(ko1/mame, guest: ima1zumi)

Top comments (0)