Ruby 4.0

  • Released at: Dec 25, 2025 (NEWS.md file)
  • Status (as of Dec 26, 2025): 4.0 is just released
  • This document first published: Dec 26, 2025
  • Last change to this document: Dec 26, 2025

🇺🇦 🇺🇦 This changelog is created as a gift to the Ruby community by a serving Ukrainian officer during the full-scale Russian invasion of Ukraine. If you find my work useful, please donate to the Ukrainian cause. This page lists many trusted grassroots fundraisers, both military and humanitarian. 🇺🇦 🇺🇦

Note: As already explained in Introduction, this site is dedicated to changes in the language, not the implementation, therefore the list below lacks mentions of lots of important CRuby implementation updates, that continue to be steadily delivered by the core team.

Highlights

Note: As Ruby doesn’t follow SemVer, the 4.0 version marks not some significant change/set of changes, but was chosen by Matz to celebrate Ruby’s 30s birthday.

Language changes

Logical operators can continue on the next line

Ruby parser now recognizes lines starting with &&/|| or and/or as continuation of the previous line, even in the presence of comments between the lines.

  • Reason: The same as “leading dot” that became possible since Ruby 1.9.1: 1) the visibility of the fact that the line is a continuation; 2) smaller diffs when the last line in a complicated statement is added/removed
  • Discussion: Feature #20925
  • Documentation: Code Layout
  • Code: Those were SyntaxError in Ruby 3.4:
    # at the end of some method, combine boolean values
    # and explain them
    
    legible?
      # We provide most of our functionality for trial users
      || trial?
      # (see, inline comments work, too!)
      || admin?
    
    some.very.long.and { involved }.calculation
      # This way, however complicated the previous line is, it is very visible
      # that its resulting in false/nil will lead to early return:
      or return
    

*nil no longer calls nil.to_a

Interpreting *nil as “no arguments” specialized to happen without producing an intermediate array.

  • Reason: When in Ruby 3.4 **nil was introduced to provide “no keyword args”, it was at once implemented optimized. Instead of just defining NilClass#to_hash (which ** operator invokes to interpret the argument), it just provides no extra keyword arguments to the method. This way, no extra methods are called, and no additional hash objects are allocated. After that change, it was proposed that *nil will be optimized the same way.
  • Discussion: Feature #21047
  • Documentation: Calling Methods: Unpacking Positional Arguments
  • Code: You probably wouldn’t notice the change in any code behavior, but to see it in effect, you can redefine #to_a to return a non-empty array, and see if Ruby respects that:
    def nil.to_a = [1, 2, 3]
    
    def m(*args) = p(args:)
    
    m(*nil)
    # Ruby 3.4: prints args: [1, 2, 3] -- uses redefinition
    # Ruby 4.0: prints args: [] -- skips redefinition and any array producing
    

Removals

  • Process creation by Kernel#open and IO with leading |, deprecated in 3.3, is removed. Discussion: Feature #19630
  • Process::Status#& and #>> have been removed. They were deprecated in Ruby 3.3. Discussion: Bug #19868
  • ObjectSpace._id2ref (a method that wasn’t documented since forever, but existed) is deprecated. Discussion: Feature #15408

Core classes and modules

Ruby: new top-level module

The Ruby module, which became a reserved constant in Ruby 3.4, became a core module that, so far, contains the Ruby::Box class and constants describing the current Ruby.

  • Discussion: Feature #20884
  • Documentation: Ruby
  • Code:
    Ruby.constants
    # => [:REVISION, :COPYRIGHT, :ENGINE, :ENGINE_VERSION,
    #     :Box, :VERSION, :RELEASE_DATE, :DESCRIPTION, :PLATFORM,
    #     :PATCHLEVEL]
    
    Ruby::VERSION #=> "4.0.0"
    
    # Same as the old RUBY_* constants, which are still available:
    RUBY_VERSION  #=> "4.0.0"
    

Object#inspect customization

By redefinining #instance_variables_to_inspect, the default output of #inspect can be adjusted.

  • Reason: While the developer can always redefine #inspect, the new way allows to reuse the default rendering code, just limiting the number of variables to render, avoiding parts of an object that are too cumbersome too render, too sensitive, or have some other reasons to be excluded.
  • Discussion: Feature #21219
  • Documentation: Object#inspect
  • Code:
    class User
      def initialize(name, password)
        @full_name = name
        @first, @last = name.split(' ')
        @password = password
      end
    
      def instance_variables_to_inspect
        [:@first, :@last]
      end
    end
    
    u = User.new('Jane Smith', 'password123')
    p u
    #<User:0x000071d1b04b07a0 @first="Jane", @last="Smith">
    
    # Any unrecognized values, or non-Symbol values (including string names of instance variables)
    # are simply ignored
    class User
      def instance_variables_to_inspect
        [:@first, :@second, ':@last']
      end
    end
    
    p u
    #<User:0x000071d1b04b07a0 @first="Jane">
    
    # Returnin `nil` from the method means "use the default rendering"
    class FullUser < User
      def instance_variables_to_inspect = nil
    end
    
    p FullUser.new('Jane Smith', 'password123')
    # #<FullUser:0x0000740daaa907f0 @full_name="Jane Smith", @first="Jane", @last="Smith", @password="password123">
    
    # Returning anything other than array or nil results in an exception
    class User
      def instance_variables_to_inspect = Set[:@first, @last]
    end
    p u
    # TypeError: Expected #instance_variables_to_inspect to return an Array or nil, but it returned Set
    
  • Notes:

String: stripping methods now accept what characters to strip

String#strip, #lstrip, #rstrip and their bang versions now accept any number of string parameters describing what characters to strip. Those parameters can be plain lists of symbols or character selectors, already used in methods like #delete, #tr or #count.

  • Discussion: Feature #21552
  • Documentation: String#strip, #strip!, #lstrip, #lstrip!, #rstrip, and #rstrip!, Character Selectors
  • Code:
    '["a bunch", "of", "complex [tokens]"]'.strip('[]')
    #=> '"a bunch", "of", "complex [tokens]"'
    
    # When several strings with selectors are provided, the characters
    # removed should match all of them, meaning that the second and
    # further arguments are most useful to exclude something.
    
    # remove characters 1-9, except 4-5:
    "Document-123456789".rstrip('1-9', '^4-5')
    #=> "Document-12345" -- removed while both conditions were matched
    

Math.log1p and Math.expm1

Math.log1p(x) is atomic ln(x + 1), Math.expm1(x) is exp(x) - 1.

  • Reason: Due to the implementation of floating-point numbers, this methods, when calculated non-atomically, lose precision. The new atomic methods are implemented to produce correct results. Such methods exist is most of the languages and math libraries (and the Ruby’s default implementation actually delegates to C ones).
  • Discussion: Feature #21527
  • Documentation: Math.log1p, Math.expm1
  • Code:
    1 + 1e-16
    # => 1.0, precision is lost
    # Thus:
    Math.log(1 + 1e-16)
    # => 0.0
    
    Math.log1p(1e-16)
    # => 1.0e-16 -- precision is preserved
    
    # Same for exp1p
    Math.exp(1.0e-16) - 1 #=> 0.0
    Math.expm1(1.0e-16)   #=> 1.0e-16
    

Range

#to_set guards against infinite loop

Range#to_set raise error when it is definitely known that the range is infinite, instead of trying to iterate it, hanging forever.

  • Discussion: , Bug #21654
  • Documentation: Range#to_set
  • Code:
    (1..).to_set
    # 'Range#to_set': cannot convert endless range to a set (RangeError)
    
    # Not with the literal infinity, though:
    (1..Float::INFINITY).to_set
    # ...hangs forever
    
  • Notes:
    • Initially on Set reimplementation #size check was introduced into its #initialize method, if the argument responded to it. But it was eventually decided to be too reckless: in general, #size is not guaranteed to be efficient (think database relations), or even faithfully represent the enumerable size. So the scope of the check was reduced to #to_set of select classes, then to just Range. See discussion in Bug #21654.
    • While it was discussed as a possibility, the range with explicitly infinite end wouldn’t raise, it will just hung.

#overlap? fixed for boundless range

  • Discussion: Bug #21185
  • Documentation: Range#overlap? (no special mention of fully-infinite ranges though)
  • Code:
    # fully infinite vs semi-infinite range
    (nil..nil).overlap?(..3)
    #=> true
    
    # the opposite:
    (..3).overlap?(nil..nil)
    # Ruby 3.4: false
    # Ruby 4.0: true
    
  • Notes: #overlap? of fully-infinite range with a semi-infinite (the first case above) was actually fixed in Ruby 3.4 (returned false in 3.3), but the official NEWS (and thus the Ruby Changes) failed to mention it (shrug).

#max works correctly on beginless integer ranges

  • Discussion: Bug #21174 Bug #21175
  • Documentation: Range#max
  • Code:
    # max was smart enough to fetch one maximum value:
    (..10).max
    # Ruby 3.4 and 4.0: => 10
    
    # but not several:
    (..10).max(3)
    # Ruby 3.4: cannot get the maximum of beginless range with custom comparison method (RangeError)
    # Ruby 4.0: => [10, 9, 8]
    
    # Exclusive end is handled correctly:
    (...10).max(3)
    #=> [10, 9, 8]
    
  • Notes: The change might be considered slightly semantically dubious: after all, it can be argued that we aren’t sure that it wasn’t a float range (started with “float infinity”):
    # Consider this:
    # (float..integer) is a correct range:
    (1.5..10).cover?(3.2) #=> true
    # but it is not iterable:
    (1.5..10).to_a
    # thus, we can't take max(2) from it:
    (1.5..10).max(2)
    
    # But we CAN take max(2) from (..10), even if in some situation
    # the developer might argue they meant a _float_ infinity:
    (..10).max(3)
    #=> [10, 9, 8]
    # Which is consistent, for example, with reverse_each:
    (..10).reverse_each.take(3)
    #=> [10, 9, 8]
    

    Matz ultimately decided that he prioritizes practicality over consistency in this case.

Array#find and #rfind

rfind (reverse find) method was added to Array, and find/detect was reimplemented (specified) for Array instead of using the generic one, provided by Enumerable.

  • Discussion: Feature #21678
  • Documentation: Array#find, Array#rfind
  • Code:
    [1, 2, 3, 4, 5].rfind { it < 5 } #=> 4
    [1, 2, 3, 4, 5].rfind { it > 5 } #=> nil
    
    # Returns an Enumerator when no block is provided:
    e = [1, 2, 3, 4, 5].rfind
    #=> #<Enumerator: ...>
    e.each { it < 5 } #=> 4
    
    # an additional proc argument allows to provide a default value
    # to return when no matching one was found:
    [1, 2, 3, 4, 5].rfind(proc { -1 }) { it > 5 } #=> -1
    
    # the proc doesn't receive any arguments:
    [1, 2, 3, 4, 5].rfind(proc { |*args| p(args:); -1 }) { it > 5 }
    # prints: {args: []}
    

    (The behavior of Array#find is not demonstrated, as it is exactly the same as Enumerable#find.)

Set

The class reimplemented efficiently

Set became “core” class in Ruby 3.2, but it was actually achieved by autoloading its Ruby implementation from the standard library. This implementation used Hash with boolean values internally and, in general, was somewhat of a “legacy” from the earlier versions of Ruby. Now set is reimplemented in C as a “proper” core class with efficient storage and API. This is mostly good news, unless your code have relied on the Set internals: instance variables or assumptions about the internal calls order. To ensure better backward compatibility, the Set::SubclassCompatible module was introduced.

  • Discussion: Feature #21216
  • Documentation: Set
  • Notes: The fact that it is reimplemented might be demonstrated by the absence of an internal instance variable @hash:
    s = Set[1, 2, 3]
    s.instance_variables
    # Ruby 3.4: [:@hash]
    # Ruby 4.0: []
    
    # So in Ruby 3.4, one could do this:
    s.instance_variable_get(:@hash)
    #=> {1=>true, 2=>true, 3=>true}
    

    In Ruby 3.4, one also could rely on particular implementation details. Example of such assumptions: Set[] always calls #initialize (which might be redefined); #initialize fills the set by invoking #add for every element.

    The new core Set doesn’t follow those assumptions. To improve compatibility, a private module Set::SubclassCompatible is introduced. It is written in Ruby, preserving most of the original Set implementation, and is included implicitly on inheritance.

    class MySet < Set
    end
    
    MySet.instance_method(:initialize)
    #=> #<UnboundMethod: Set::SubclassCompatible#ininitialize(enum=, &block) .../set/subclass_compatible.rb:41>
    # ^ old Ruby version of the method,
    # while the core Set now uses C one:
    Set.instance_method(:initialize)
    #=> #<UnboundMethod: Set#initialize(_)>
    
    # Now, as methods of SubclassCompatible are implemented mostly as they were before,
    # old assumptions work:
    class SmallStringSet
      def initialize(items) = super(items.to_a[...5])
      def add(item) = super(item.to_s)
    end
    
    SmallStringSet[*(1..10)]
    #=> #<SmallStringSet: {"1", "2", "3", "4", "5"}>
    # [] invoked #initialize which limited the set size,
    # and then invoked #add, which converted elementes
    
    # Note that if your code relied on `@hash` instance variable directly
    # (which it shouldn't have), it will be broken despite the compatibility
    # layer:
    class SmallStringsSet
      # Somebody decided using internal variable would be more efficient
      def numeric? = @hash.keys.all?(Numeric)
    end
    
    SmallStringSet[1, 2, 3].numeric?
    # 'SmallStringSet#numeric?': undefined method 'keys' for nil (NoMethodError)
    
    # If you don't need the compatibility layer, it is recommended
    # to inherit from Set::CoreSet: a private empty Set subclass
    # that just signals to not use the compatibility layer:
    class SmallStringSet2 < Set::CoreSet
    end
    
    SmallStringSet2.instance_method(:initialize)
    #=> #<UnboundMethod: Set#initialize(_)>
    
    # It would be faster, but the assumptions which methods calls which
    # internally aren't necessary correct anymore
    class SmallStringSet2
      def initialize(items) = super(items.to_a[...5])
      def add(item) = super(item.to_s)
    end
    
    p SmallStringSet2[*(1..10)]
    #=> SmallStringSet2[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    # Neither redefined #initialize nor redefined #add were called by .[]
    

#inspect output change

As Set became a “properly core” class, it was decided to change its #inspect output, so it would be closer to the syntax needed to create a set. As sets doesn’t have their own literals in Ruby, the representation chosen is Set[list, of, elements].

  • Discussion: Feature #21389
  • Documentation: Set#inspect
  • Code:
    p Set[1, 2, 3]
    # Ruby 3.4: #<Set: {1, 2, 3}>
    # Ruby 4.0: Set[1, 2, 3]
    
    # Like #inspect of Array, Hash, or String, this representation can
    # be eval-ed back to the same data:
    eval("Set[1, 2, 3]") #=> Set[1, 2, 3]
    
    # In accordance to what explained above, classes inherited from Set
    # keep the old #inspect behavior:
    class MySet < Set
    end
    
    p MySet[1, 2, 3]
    # #<MySet: {1, 2, 3}>
    
    # ...while inheriting from CoreSet brings the new behavior:
    class MySet2 < Set::CoreSet
    end
    
    p MySet2[1, 2, 3]
    # MySet2[1, 2, 3]
    

#to_set methods shouldn’t be used with arguments

Previously, the Set#to_set and Enumerable#to_set methods had a peculiar signature of to_set(target_class, *additional_args, &block), which was intended to convert between different Set subclasses. This usage is deprecated.

  • Reason: The convention was overengineered and unlike any other conversion method in the Ruby core:
    # Intended usage (slightly overcomplicated to make a point):
    class TaggedSet < Set
      attr_reader :tag
    
      def initialize(contents, tag, &block)
        @tag = block.call(tag)
        super(contents, &nil)
      end
    end
    
    tagged = Set[1, 2, 3].to_set(TaggedSet, 'test', &:upcase)
    #=> #<TaggedSet: {1, 2, 3}>
    tagged.tag
    #=> TEST
    
    # Unintended yet totally possible usage:
    # successfully calls Hash.new(Set[1, 2, 3]), which
    # creates an empty hash with the default value set
    h = Set[1, 2, 3].to_set(Hash)
    #=> {}
    h.default #=> Set[1, 2, 3]
    

    Usually, classed do not have conversion method arguments that allow to specify “into which type they should convert,” and it is better to implement as specialized methods if needed.

  • Discussion: Feature #21390
  • Documentation: Set#to_set, Enumerable#to_set.
  • Code:
    Warning[:deprecated] = true
    
    Set[1, 2, 3].to_set(Hash)
    # warning: passing arguments to Set#to_set is deprecated
    

Proc#parameters output tweak

Implicit it parameter is now reported as [:opt] instead of [:opt, nil].

  • Reason: Just a small fix to improve consistency (when it was required – like in lambdas, they’ve returned no extra nil).
  • Discussion: Bug #20974
  • Documentation: Proc#parameters
  • Code:
    -> { it + 1 }.parameters
    # Ruby 3.4 and 4.0: => [[:req]]
    proc { it + 1 }.parameters
    # Ruby 3.4: => [[:opt, nil]]
    # Ruby 4.0: => [[:opt]] -- now consistent with lambda
    
    # With numbered parameters, the name is still returned, even in 4.0:
    -> { _1 + 1 }.parameters
    #=> [[:req, :_1]]
    proc { _1 + 1 }.parameters
    #=> [[:opt, :_1]]
    

Enumerator.produce: new size: argument

The argument allows to provide the meta-information about the number of iterations, if it is known beforehand, like Enumerator.new allows.

  • Discussion: Feature #21701
  • Documentation: Enumerator.produce
  • Code:
    e = Enumerator.produce(Date.today, &:succ)
    e.size #=> Infinity (the default)
    
    # Provide a size when it is known beforehand:
    linecount = `wc -l file.txt`.strip.to_i
    f = File.open('file.txt')
    e = Enumertor.produce(size: linecount) {
      raise StopIteration if f.eof?
      f.readline
    }
    e.size #=> 10
    
    # Provide a "size is unknown, but probably finite" hint:
    e = Enumerator.produce(Date.today, size: nil) {
      it.monday? ? raise(StopIteration) : it + 1
    }
    e.size #=> nil
    
  • Notes:
    • One might argue (and there was in fact an argument) that making Infinity the default is misleading: as we don’t know if the block raises a StopIteration, the size in generally unknown, i.e. nil. The final choice was made based on a backward compatibility (it returned Infinity before the ability to provide the size was possible) and the “default” semantics of the simplest form (when the exception is not used).

Pathname became a core class

Pathname is an object-oriented wrapper around the filesystem object. It allows to process idiomatically objects metadata (like modification time), read them (like listing directories or reading files), and modify (like creating directories and writing files). It can now be used without require "pathname" (save for three complex methods, see below).

  • Discussion: Feature #17473
  • Documentation: Pathname
  • Notes:
    • Several methods aren’t in the core class, as they depend on additional libraries: #rmtree and .mktmpdir depend on fileutils standard library, while #find depends on find standard library. To access them, your code needs to explicitly require 'pathname' which requires a small standard library file that now defines just those three methods (the same way as require 'time' makes Time.parse available). When the methods are called, the necessary additional libraries are required:
      Pathname.mktmpdir
      # undefined method 'mktmpdir' for class Pathname (NoMethodError)
      $LOADED_FEATURES.grep(/fileutils/) #=> [] -- fileutils not loaded either
      
      require 'pathname'
      $LOADED_FEATURES.grep(/fileutils/) #=> [] -- not loaded until necessary
      
      Pathname.mktmpdir  #=> #<Pathname:/tmp/d20251220-973237-xv7tv1>
      $LOADED_FEATURES.grep(/fileutils/)
      #=> [".../lib/ruby/4.0.0+1/fileutils.rb"] -- now it was loaded in the method
      
    • Another method, #mkpath, that have previously depended on fileutils, is reimplemented in the core Pathname itself. It might be that the rest of them will be reimplemented, too.
    • There is also a discussion of adding more methods as an optional ones available, but it is not decided yet.

Ruby::Box: a new concept for isolated code loading

The current state of the concept is somewhat drafty/experimental, yet it lays the groundwork for loading of libraries into their own isolated namespaces, transparently for those libraries.

  • Discussion: Feature #21311
  • Documentation: Ruby::Box
  • Code: Note that all of the examples will only work if an environment variable RUBY_BOX=1 is set.
    box = Ruby::Box.new
    #=> #<Ruby::Box:3,user,optional>
    
    # Now we can load and execute some code in the box,
    # via require, require_relative, load, or eval:
    
    box.require('cgi')
    
    box::CGI #=> CGI
    box.eval('CGI.escape("test me")')
    #=> "test+me"
    
    CGI
    # uninitialized constant CGI (NameError) -- it is loaded only in the box
    
    # Some introspection of the "boxing" state is available:
    Ruby::Box.current                   #=> #<Ruby::Box:2,user,main>
    box.eval('Ruby::Box.current')       #=> #<Ruby::Box:3,user,optional>
    Ruby::Box.main                      #=> #<Ruby::Box:2,user,main>
    Ruby::Box.current.main?             #=> true
    box.eval('Ruby::Box.current.main?') #=> false
    
    # Ruby's core classes are the same over the box boundary
    
    string_from_box = box.eval('"foo"')
    string_from_box.is_a?(String) #=> true
    
    # Though their monkey-patches are contained in the box:
    box.eval(<<~RUBY)
      class String
        def lowercase? = downcase == self
      end
    RUBY
    
    box.eval('"foo".lowercase?') #=> true
    p string_from_box.lowercase?
    # undefined method 'lowercase?' for an instance of String (NoMethodError)
    
    # Non-core classes in the box and outside of it are
    # different, even if loaded from same libraries
    box.require('cgi')
    
    require 'cgi'
    
    CGI == box::CGI    #=> false
    CGI.object_id      #=> 144
    box::CGI.object_id #=> 160
    
    # But objects and classes from boxes can be used outside of the box:
    box::CGI.escape('test me')
    #=> "test+me"
    
    # _Somewhat_ realistic example, limited by boxes experimental state.
    #
    # Imagine we have two dependencies we need, one of them is dependent
    # on Faraday < 2, while another on Faraday 2+.
    # Today, you just can't use them together.
    # With boxes, you might.
    #
    # Note: Currently, `gem "name"` doesn't work in boxes, so
    # the example kinda imitates RubyGems here, manually adjusting the
    # load paths.
    box1 = Ruby::Box.new
    box1.eval(<<~RUBY)
      # imitating gem "faraday", "2.14"
      $LOAD_PATH.unshift '/home/zverok/.rbenv/versions/4.0-dev/lib/ruby/gems/4.0.0+1/gems/faraday-2.14.0/lib/'
      require 'faraday'
    RUBY
    
    box2 = Ruby::Box.new
    box2.eval(<<~RUBY)
      # imitating gem "faraday", "1.10"
      $LOAD_PATH.unshift '/home/zverok/.rbenv/versions/4.0-dev/lib/ruby/gems/4.0.0+1/gems/faraday-1.10.0/lib/'
      # and same for its mandatory dependency
      $LOAD_PATH.unshift '/home/zverok/.rbenv/versions/4.0-dev/lib/ruby/gems/4.0.0+1/gems/faraday-net_http-1.0.2/lib/'
      require 'faraday'
    RUBY
    
    # Outside of boxes, load path is unaffected
    $LOAD_PATH.grep(/faraday/) #=> []
    
    # Now, two "incompatible" versions of the same library
    # can coexist in the app:
    box1::Faraday::VERSION #=> "2.14.0"
    box2::Faraday::VERSION #=> "1.10.0"
    
    # And perfectly functional, too:
    box1::Faraday.get('http://example.com') #=> #<Faraday::Response....
    box2::Faraday.get('http://example.com') #=> #<Faraday::Response....
    
  • Notes: The current state of the feature is “experimental”, and enabling RUBY_BOX = 1 might break code even if you don’t use the Box in it. That’s why I am not trying to cover it in more details. Maybe next year. At least, the name and the API seem to be set upon.

Binding: separate local variables from implicit parameters

Binding#local_variables method now exclude numbered (implicit) parameters, and methods like #local_varaible_get raise an error for those names. Instead, new method group #implicit_parameters/#implicit_parameter_get/#implicit_parameter_defined? was introduced to refer to both kinds of implicit parameters (numbered and it).

  • Reason: It was decided, that exposing numbered parameters as “local variables” is confusing and misleading (say, you couldn’t call _1 = value, but accidentally binding.local_variable_set(:_1, value) worked). So the concepts were separated.
  • Discussion: Bug #21049
  • Affected methods: Binding#local_variables, #local_variable_defined?, #local_variable_get,#local_variable_set
  • New methods: #implicit_parameters, #implicit_parameter_get, #implicit_parameter_defined?
  • Code:
    ['a'].each {
      _1 # make it exist in block
      p(local_variables: binding.local_variables)
      p(defined: binding.local_variable_defined?(:_1))
      p(value: binding.local_variable_get(:_1))
    }
    # In Ruby 3.4, this would print:
    #   {local_variables: [:_1]}
    #   {defined: true}
    #   {value: "a"}
    # In Ruby 4.0
    #   {local_variables: []}
    # And then raise:
    #   'Binding#local_variable_defined?': numbered parameter '_1' is not a local variable (NameError)
    
    # Using Ruby 4.0 new methods:
    ['a'].each {
      _1 # make it exist in block
      p(parameters: binding.implicit_parameters)
      p(defined: binding.implicit_parameter_defined?(:_1))
      p(value: binding.implicit_parameter_get(:_1))
    }
    # Prints:
    #  {parameters: [:_1]}
    #  {defined: true}
    #  {value: "a"}
    
    # numbered parameters or `it` are available if the block references them
    proc { binding.implicit_parameters }.call(1) #=> []
    proc { _1; binding.implicit_parameters }.call(1) #=> [:_1]
    proc { it; binding.implicit_parameters }.call(1) #=> [:it]
    
    # the reference can be anywhere in the code, before or after
    # the binding method calls:
    proc {
      p binding.implicit_parameters
      _1
    }.call(1) #=> [:_1]
    
    # Numbered parameter are getting available from _1 to the one referred:
    proc { _4; binding.implicit_parameters }.call(1)
    #=> [:_1, :_2, :_3, :_4]
    
    # Trying to get a non-referred one gets a NameError:
    proc { binding.implicit_parameter_get(:_1) }.call(1)
    # implicit parameter '_1' is not defined for #<Binding:0x000073fda7107570> (NameError)
    
    # we have _1-_3, but not _4
    proc { _3; binding.implicit_parameter_get(:_4) }.call(1)
    # implicit parameter '_4' is not defined for #<Binding:0x000073fda7714300> (NameError)
    
  • Notes:
    • When it was introduced in Ruby 3.4, it was not exposed as a local variable (unlike numbered parameters introduced in 2.7). After discussing this inconsistency, it was decided that better not to consider either the “local variables” (which numbered parameters historically were, as they were introduced “softly” and one could’ve use them as such initially).
    • While #implicit_parameters and related methods provide some amount of metaprogramming access to _1 and it, Binding#eval doesn’t support them, to not complicate the Binding context for questionable gains:
      proc { _1; binding.eval('_1') }.call
      # undefined local variable or method '_1' for main (NameError)
      

      See the discussion.

Exceptions

<internal: frames are remove from backtraces

Ruby core methods that are implemented in Ruby were reflected in error and caller backtraces as <internal:{filename}>:{lineno} before, now such lines are filtered out.

  • Reason: As there is no way to follow the <internal: file links, these parts were considered useless. It is also confusing: when a core method is reimplemented in terms of another, the backtrace names the “implementation detail” method as the first culprit.
  • Discussion: Bug #20968
  • Documentation:
  • Code:
    # Trying to fetch non-existent indices
    [1, 2, 3].fetch_values(5, 6)
    # Ruby 3.4: exposes internal methods on top of the callstack
    #   <internal:array>:211:in 'Array#fetch': index 5 outside of array bounds: -3...3 (IndexError)
    #     from <internal:array>:211:in 'block in Array#fetch_values'
    #     from <internal:array>:211:in 'Array#map!'
    #     from <internal:array>:211:in 'Array#fetch_values'
    #     from test.rb:1:in '<main>'
    # Ruby 4.0: just shows the problematic "entrypoint"
    #   test.rb:1:in 'Array#fetch_values': index 5 outside of array bounds: -3...3 (IndexError)
    #     from test.rb:1:in '<main>'
    
    # Now it is consistent with how C-implemented methods report:
    [1, 2, 3].fetch(5)
    # Ruby 3.4 and 4.0:
    #   test.rb:1:in 'Array#fetch': index 5 outside of array bounds: -3...3 (IndexError)
    #     from test.rb:1:in '<main>'
    
    # The change also affects methods like caller or caller_locations:
    
    # The block is called when the index is not found
    [1, 2, 3].fetch_values(5) { p caller }
    # Ruby 3.4:
    #   ["<internal:array>:211:in 'Array#fetch'", "<internal:array>:211:in 'block in Array#fetch_values'", "<internal:array>:211:in 'Array#map!'", "<internal:array>:211:in 'Array#fetch_values'", "test.rb:1:in '<main>'"]
    # Ruby 4.0:
    #   ["test.rb:1:in 'Array#fetch_values'", "test.rb:1:in '<main>'"]
    

ArgumentError: backtrace includes receiver’s class/module name

  • Reason: This is just a fix of the omission for this particular exception, after receiver’s names were added to backtraces in Ruby 3.4.
  • Discussion: Bug #21698
  • Documentation: ArgumentError (doesn’t show the backtrace anyway)
  • Code: See examples in the below section (note the presence of CSV# in them).

ArgumentError output includes code of caller and callee

Technically, this is provided by the ErrorHighlight standard library, which is enabled by default.

  • Discussion: Feature #21543
  • Documentation:
  • Code: Considering this test.rb (wrong call to CSV.new)
    require 'csv'
    
    CSV.new(1, 2, 3)
    

    On Ruby 3.4, the output of ruby test.rb will be:

    .../lib/csv.rb:2034:in 'initialize': wrong number of arguments (given 3, expected 1) (ArgumentError)
    from ra.rb:3:in 'Class#new'
    from ra.rb:3:in '<main>'
    

    While on Ruby 4.0, it will be:

    .../lib/csv.rb:2034:in 'CSV#initialize':  wrong number of arguments (given 3, expected 1) (ArgumentError)
    
        caller: ra.rb:3
        | CSV.new(1, 2, 3)
             ^^^^
        callee: .../lib/csv.rb:2034
        |   def initialize(data,
                ^^^^^^^^^^
      from test.rb:3:in '<main>'
    

Thread#raise/Fiber#raise: cause: keyword argument

  • Reason: Kernel#raise (aka top-level raise) had a cause: argument since Ruby 2.1. It allows to provide the synthetic nested reason for the exception, or, vice versa, to cleanup the default reason when raise is invoked from the rescue block (when Ruby by default fills cause: with the caught exception) to hide irrelevant implementation details.
  • Discussion: Feature #21360
  • Documentation: Thread#raise, Fiber#raise
  • Code:
    f = Fiber.new do
      Fiber.yield 1
    end
    
    f.resume
    
    f.raise RuntimeError, "something went wrong", cause: ArgumentError.new("Value is too big")
    # Fails with printing both the error and its cause:
    #   test.rb:2:in 'Fiber.yield': something went wrong (RuntimeError)
    #     from test.rb:2:in 'block in <main>'
    #   test.rb: Value is too big (ArgumentError)
    
    # Or, somewhat more realistically:
    def processor(fiber)
      while fiber.alive?
        value = fiber.resume
        do_some_procesing(value)
      rescue => e
        # We want fiber to be terminated by an error, but don't want
        # to expose our internal processing details to it.
        fiber.raise RuntimeError, "An incorrect value was calculated", cause: nil
      end
    end
    

IO.select accepts Float::INFINITY as a timeout argument

The meaning is the same as nil (no timeout).

  • Reason: In some cases, it is more convenient for the user code to store and pass a non-empty Float value (comparable/sortable with other float values) as a desired timeout to pass to select at some point.
  • Discussion: Feature #20610
  • Documentation: IO.select

Socket.tcp & TCPSocket.new: accepts open_timeout: argument

New timeout argument has a semantics of “overall timeout”: how much time since the call of the method shall pass before the timeout is raised.

  • Reason: TCP sockets have separate resolv_timeout and connect_timeout, but the current implementation of the algorithm (which is heavily parallelized and have several optimizations) makes their interaction murky, with the naive expectation that “method will fail after resolve_timeout+connect_timeout on connection problem” broken in many possible ways. Please read the discussion link below, the author of the change explains it in great details.
  • Discussion: Feature #21347
  • Documentation: Socket.tcp, TCPSocket.new

Ractor improvements

Ractor::Port

Ports are a new straightforward mechanism for bidirectional exchange of data between ractors. Port can be passed between ractors on creation or #send/#<<. The ractor that has the reference to a port can write to it, and the ractor that created the port can read from it.

  • Reason: The previous APIs of #send/#receive/#yield made it hard to implement client-server model: when the “server” ractor received messages from many “client” ractors, it was cumbersome to decide how to send the answer to the corresponding client. Ports, as simple shareable values designating the receiver, can be easily shared between ractors and allow to implement any communication structures naturally.
  • Discussion: Feature #21262
  • Documentation: Ractor::Port; Ractor#default_port: Ractor#send and Ractor.receive are now synonyms for ractor.default_port.send and ractor.default_port.receive.
  • Code:
    calculator = Ractor.new {
      loop do
        *arguments, client_port = Ractor.receive
        result = "Some calculation with #{arguments}"
        client_port << result
      end
    }
    
    port1 = Ractor::Port.new
    calculator << [1, 2, 3, port1]
    
    port2 = Ractor::Port.new
    calculator << ["something else", port2]
    
    port1.receive
    #=> "Some calculation with [1, 2, 3]"
    port2.receive
    #=> 'Some calculation with ["something else"]'
    
    # Like any shareable value, the port reference can be shared
    # with a ractor by send (as demonstrated above) or as
    # argument on its creation:
    monitoring_port = Ractor::Port.new
    
    Ractor.new(monitoring_port) { |p| p << 'something happened' }
    monitoring_port.receive #=> "something happened"
    
    # Only the ractor that created the port can read from it:
    port = Ractor::Port.new
    port << 1
    
    Ractor.new(port) { |p| p.receive }
    # Ractor::Port#receive': only allowed from the creator Ractor of this port (Ractor::Error)
    
    # But any number of ractors can write to it:
    
    port = Ractor::Port.new
    
    Ractor.new(port) { |p| p << 1 }
    Ractor.new(port) { |p| p << 2 }
    Ractor.new(port) { |p| p << 3 }
    
    3.times.map { port.receive } #=> [1, 2, 3]
    
    # Port#receive blocks the receiving ractor, till it waits
    # for the value:
    port = Ractor::Port.new
    
    Ractor.new(port) { |p|
      sleep(1)
      p << 'test'
    }
    
    puts "Starting to receive at #{Time.now}"
    v = port.receive
    puts "Received #{v} at #{Time.now}"
    # Prints:
    #   Starting to receive at 2025-12-21 18:46:43
    #   Received test at 2025-12-21 18:46:44
    
    # Port#send doesn't block the sending ractor:
    port = Ractor::Port.new
    
    Ractor.new(port) { |p|
      p << 1
      puts "Done sending at #{Time.now}"
    }
    
    puts "Sleeping before receive at #{Time.now}"
    sleep(1)
    v = port.receive
    puts "Received #{v} at #{Time.now}"
    # Sleeping before receive at 2025-12-21 18:48:26
    # Done sending at 2025-12-21 18:48:26
    # Received 1 at 2025-12-21 18:48:27
    
    # By default, Port#send copies unshareable values, but it can
    # be adjusted my `move:` argument:
    port = Ractor::Port.new
    Ractor.new(port) { |p|
      ary = [1, 2, 3]
      ary.object_id #=> 16
      port.send(ary)
      ary #=> [1, 2, 3], still accessible
    }
    out = port.receive
    p out.object_id #=> 24 -- different from one inside the ractor
    
    Ractor.new(port) { |p|
      ary = [1, 2, 3]
      ary.object_id #=> 16
      port.send(ary, move: true)
      ary # undefined method 'inspect' for an instance of Ractor::MovedObject
    }
    out = port.receive
    p out.object_id #=> 16, same as inside the ractor
    
    # The port can be closed by port.close, which means it can't
    # be sent to or received from:
    monitoring_port = Ractor::Port.new
    
    Ractor.new(monitoring_port) { |p|
      p << 'something happened'
      # switches context to the calling ractor, allowing it to close
      # the port before receiving something else
      Ractor.receive
      p << 'more things happened'
    }
    
    monitoring_port.receive #=> "something happened"
    monitoring_port.close
    monitoring_port.receive
    # 'Ractor::Port#receive': The port was already closed (Ractor::ClosedError)
    
    # Only the port owner can close it:
    monitoring_port = Ractor::Port.new
    
    Ractor.new(monitoring_port) { |p| p.close }.join
    # closing port by other ractors is not allowed (Ractor::Error)
    
  • Notes:
    • This change also supersedes and removes APIs Ractor#close_outgoing/#close_incoming (replaced by Ractor::Port#close).
    • Note that those mentioned methods already have referred to “incoming and outgoing ports” of the ractor, but the concept wasn’t “materialized” as some class/API previously.
    • Ractor.yield (“send the message to whoever listens”) is also removed, the only ways to send the data out of the ractor are either through a port, or as a return value of the whole Ractor block.

.select semantic changed

After introducing ports and removing generic Ractor.yield, Ractor.select now accepts a list of ractors or ports, and will select either a port that has a message, or a ractor that is terminated.

  • Discussion:
  • Documentation: Ractor.select
  • Code:
    port = Ractor::Port.new
    
    r1 = Ractor.new(name: 'r1') { 'value of ractor 1' } # first
    r2 = Ractor.new(port, name: 'r2') { |p|
      sleep(0.1)
      p << 'value to port' # second
      sleep(0.2)
      'value of ractor 2' # last
    }
    r3 = Ractor.new(name: 'r3') {
      sleep(0.2)
      'value of ractor 3' # after value to port, but before finish of r2
    }
    
    p Ractor.select(r1, r2, r3, port)
    #=> [#<Ractor:#2 r1 terminated>, "value of ractor 1"]
    p Ractor.select(r1, r2, r3, port)
    #=> [#<Ractor:#2 r1 terminated>, "value of ractor 1"]
    # we will still receive the same already selected value
    # from a terminated ractor.
    # so we shouldn't pass it to select:
    p Ractor.select(r2, r3, port)
    #=> [#<Ractor::Port to:#1 id:1>, "value to port"]
    
    p Ractor.select(r2, r3, port)
    #=> [#<Ractor:#3 r3 terminated>, "value of ractor 3"]
    
    p Ractor.select(r2, port)
    #=> [#<Ractor:#4 r2 terminated>, "value of ractor 2"]
    
  • Notes: As the values that select returns are now either produced at the end of the Ractor, or sent to port, that allows to specify the move semantics when sending, extra select parameters yield_value: (what to answer to Ractor.yield) and move: (whether to move yielded object out of the ractor) are removed.

Methods for waiting for a ractor

Instead of #take, there are now #join (wait till ractor ends its execution) and #value (#join + provide the return value), and also lower-level methdods #monitor and #unmonitor that provide Port-based abstractions to implement #join and alike methods.

  • Discussion:
  • Documentation: Ractor#join, #value, #monitor, #unmonitor
  • Code:
    # #join blocks till the ractor is finished, and returns the ractor
    Ractor.new { 5 }.join #=> #<Ractor:#2 terminated>
    # While #value waits till the ractor is finished and returns
    # the last calculated value
    Ractor.new { 5 }.value #=> 5
    
    # If there is an exception thrown in ractor, both methods
    # raise, nesting the exception as a cause in Ractor::RemoteError
    Ractor.new { raise 'err' }.join
    # 'Ractor#join': thrown by remote Ractor. (Ractor::RemoteError)
    # caused by: err (RuntimeError)
    

    Internally, the methods are implemented in terms of #monitor and #unmonitor (which are public lower-level methods the user code can also use):

    port = Ractor::Port.new
    
    r = Ractor.new { }
    r.monitor(port)
    # After #monitor, #receive on the port blocks till the ractor ends,
    # and then receives either `:exited`...
    port.receive #=> :exited
    
    # Or, if there was an exception, `:aborted`:
    r = Ractor.new { raise 'err' }
    r.monitor(port)
    port.receive #=> :aborted
    
    # #unmonitor just removes the port from those that
    # will receive the messages:
    r = Ractor.new { sleep(0.5) }
    r.monitor(port)
    r.unmonitor(port) # while ractor still slept
    port.receive # -- hangs forever, the ractor won't send any messages
    

.shareable_proc and .shareable_lambda

The methods creates a proc/lambda from a provided block as a “shareable” one: i.e. such that can be safely passed between ractors. This means controlling that it doesn’t refer any (unshareable) values from the outside scope, explicitly or implicitly.

  • Discussion: Feature #21550, Feature #21557
  • Documentation: Ractor.shareable_proc, Ractor.shareable_lambda
  • Code:
    p = proc { puts "test" }
    
    Ractor.new(p) { |prc| prc.call }.join
    # 'Ractor.new': allocator undefined for Proc (TypeError)
    
    p2 = Ractor.shareable_proc(&p)
    Ractor.new(p2) { |prc| prc.call }.join
    # prints "test"
    
    # If the proc references the outside scope, it can be made shareable
    # as long as the referenced variables are shareable AND only assigned
    # once in the scope:
    
    a = 1
    Ractor.shareable_proc { p a }
    # => ok
    
    ary = [1, 2, 3]
    Ractor.shareable_proc { p ary }
    # cannot make a shareable Proc because it can refer unshareable object [1, 2, 3] from variable 'ary' (Ractor::IsolationError)
    
    b = 1
    b = 2
    Ractor.shareable_proc { p b }
    #=> cannot make a shareable Proc because the outer variable 'b' may be reassigned. (Ractor::IsolationError)
    
    # Inside the proc, `self` by default would be `nil`, but
    
    p = Ractor.shareable_proc { p self }
    Ractor.new(p) { |prc| prc.call }.join
    # prints nil
    
    p = Ractor.shareable_proc(self: 1) { p self }
    Ractor.new(p) { |prc| prc.call }.join
    # prints 1
    
    # If the provided value is not shareable, there would be an exception:
    p2 = Ractor.shareable_proc(self: [1, 2, 3]) { p self }
    # self should be shareable: [1, 2, 3] (Ractor::IsolationError)
    
    # Note that proc/lambda distinction of the method names designates what
    # they produce if used with the literal blocks. They don't change
    # the lambdiness of a pre-created proc:
    l = -> { puts "test" }
    Ractor.shareable_proc(&l)
    #=> #<Proc:0x00... test.rb:2 (lambda)> -- still a lambda
    
    # So far, procs created by Method#to_proc are not supported:
    Ractor.shareable_proc(&Kernel.method(:puts))
    # 'Ractor.shareable_proc': not supported yet (RuntimeError)
    

Fiber::Scheduler

Just a reminder: Fiber::Scheduler is a theoretical interface that should be implemented by the fiber scheduler provided by the user. Ruby doesn’t come with a default one. The methods described below are new hooks that are expected to be implemented by the scheduler, for Ruby core code to call them.

#fiber_interrupt

Invoked when Ruby needs to signal a waiting fiber that it would not be able to resume successfully. For example, when a fiber waits for IO object to be ready, but this IO object is closed, the scheduler will receive fiber_interrupt(waiting_fiber, IOError.new("stream closed in another thread")).

#yield

Generic method to yield control to the scheduler, currently invoked when the thread is interrupted by a signal like SIGINT, giving the scheduler a chance to handle that signal.

#io_close

A hook that is called when IO object is closed. Unlike io_read/io_write, passes a numeric descriptor of an IO object, not an object itself.

#io_write is invoked on buffer flush

Standard library

Since Ruby 3.1 release, most of the standard library is extracted to either default or bundled gems; their development happens in separate repositories, and changelogs are either maintained there, or absent altogether. Either way, their changes aren’t mentioned in the combined Ruby changelog, and I’ll not be trying to follow all of them.

stdgems.org project has a nice explanations of default and bundled gems concepts, as well as a list of currently gemified libraries and links to their docs.

“For the rest of us” this means libraries development extracted into separate GitHub repositories, and they are just packaged with main Ruby before release. It means you can do issue/PR to any of them independently, without going through more tough development process of the core Ruby.

Version updates

A notable update is RubyGems/Bundler 4, see the official blog post for the changes description and upgrade advice.

Standard library content changes

Default gems that became bundled

This means that if your dependencies are managed by Bundler and your code depend on one of them, they should now be added to a Gemfile.

New default gems

Removals

  • cgi library is removed from the standard library. It was an assorted bag of helpers to help web server development, created in the times when the practices and terminology were different, and the Commong Gateway Interface aka CGI was the way to use scripting languages on the server. The library was removed, while leaving a small set of web-related escaping methods in the much smaller cgi/escape standard library. Discussion: Feature #21258
  • sorted_set was previous a part of Set standard library. As it depends on another gem (rbtree), and outdated implementation details of the Set, it is dropped from the standard library altogether.

Side effects of the changelog

While working on the changelog during December of each year, I usually notice areas where new (or updated) behavior is slightly unclear, or where the documentation is lacking. So, the side effect on this year’s work on the changelog is:

If you find my work useful, please donate to the Ukrainian cause.

Toretsk, July 2025 (instagram/libkos)

Toretsk city ruined by Russian army in attempt to take it, by Kostyantyn and Vlada Liberov