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.
- Support for line breaks before logical operators
Setreimplemented efficientlyPathnamebecame a core classRuby::BoxRactor: ports
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
SyntaxErrorin 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
**nilwas introduced to provide “no keyword args”, it was at once implemented optimized. Instead of just definingNilClass#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*nilwill 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_ato 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#openandIOwith 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 #19868ObjectSpace._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
Setreimplementation#sizecheck was introduced into its#initializemethod, if the argument responded to it. But it was eventually decided to be too reckless: in general,#sizeis 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_setof select classes, then to justRange. 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.
- Initially on
#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 (returnedfalsein 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#findis not demonstrated, as it is exactly the same asEnumerable#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);#initializefills the set by invoking#addfor every element.The new core Set doesn’t follow those assumptions. To improve compatibility, a private module
Set::SubclassCompatibleis introduced. It is written in Ruby, preserving most of the originalSetimplementation, 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
itwas required – like in lambdas, they’ve returned no extranil). - 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
Infinitythe default is misleading: as we don’t know if the block raises aStopIteration, the size in generally unknown, i.e.nil. The final choice was made based on a backward compatibility (it returnedInfinitybefore the ability to provide the size was possible) and the “default” semantics of the simplest form (when the exception is not used).
- One might argue (and there was in fact an argument) that making
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:
#rmtreeand.mktmpdirdepend onfileutilsstandard library, while#finddepends onfindstandard library. To access them, your code needs to explicitlyrequire 'pathname'which requires a small standard library file that now defines just those three methods (the same way asrequire 'time'makesTime.parseavailable). 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 onfileutils, is reimplemented in the corePathnameitself. 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.
- Several methods aren’t in the core class, as they depend on additional libraries:
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=1is 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 = 1might break code even if you don’t use theBoxin 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 accidentallybinding.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
itwas 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_parametersand related methods provide some amount of metaprogramming access to_1andit,Binding#evaldoesn’t support them, to not complicate theBindingcontext for questionable gains:proc { _1; binding.eval('_1') }.call # undefined local variable or method '_1' for main (NameError)See the discussion.
- When
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 toCSV.new)require 'csv' CSV.new(1, 2, 3)On Ruby 3.4, the output of
ruby test.rbwill 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-levelraise) had acause:argument since Ruby 2.1. It allows to provide the synthetic nested reason for the exception, or, vice versa, to cleanup the default reason whenraiseis invoked from therescueblock (when Ruby by default fillscause: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
selectat 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_timeoutandconnect_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 afterresolve_timeout+connect_timeouton 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/#yieldmade 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#sendandRactor.receiveare now synonyms forractor.default_port.sendandractor.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 byRactor::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.
- This change also supersedes and removes APIs
.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
selectreturns are now either produced at the end of the Ractor, or sent to port, that allows to specify the move semantics when sending, extraselectparametersyield_value:(what to answer toRactor.yield) andmove:(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
#monitorand#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")).
- Discussion: Feature #21166
- Documentation:
Fiber::Scheduler#fiber_interrupt
#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.
- Discussion: Bug #21633
- Documentation:
Fiber::Scheduler#yield
#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.
- Discussion: —
- Documentation:
Fiber::Scheduler#io_close
#io_write is invoked on buffer flush
- Discussion: Bug #21789
- Documentation:
Fiber::Scheduler#io_write
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.
- See the official NEWS-file for the rest of version changes.
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/escapestandard library. Discussion: Feature #21258 - sorted_set was previous a part of
Setstandard library. As it depends on another gem (rbtree), and outdated implementation details of theSet, 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:
- Documentation adjustments:
- Updating
Setdocumentation - Many small core docs updates to match new behaviors
- Adjust
Enumerator.size-related docs for clarity - Base code layout rules (line breaks, line continuations and such): while trying to point to docs about new boolean operators linebreak rules, I noticed there was no official description about how the expressions are separated by linebreaks and other tokens in Ruby. Now, there is.
Fiber::Schedulerdocs- More clear
CGI/CGI.escapedocs
- Updating
- Discussions/proposals:
Enumerator.produce:size:argument logicRuby::Boxdetails: 1, 2
If you find my work useful, please donate to the Ukrainian cause.

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