Ruby 3.0

  • Released at: Dec 25, 2020 ( file)
  • Status (as of Jan 09, 2021): Stable, just released
  • This document first published: Dec 25, 2020
  • Last change to this document: Jan 09, 2021


Ruby 3.0 is a major language release. The core team worked hard to preserve backward compatibility while delivering some huge and exciting new features.

Language changes

Keyword arguments are now fully separated from positional arguments

The separation which started in 2.7 with deprecations, is now fully finished. It means keyword arguments are not a “syntax sugar” on top of hashes, and they never converted into each other implicitly:

  • Discussion: Feature #14183
  • Code:
    def old_style(name, options = {})
    def new_style(name, **options)
    new_style('John', {age: 10})
    # Ruby 2.6: works
    # Ruby 2.7: warns: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call
    # Ruby 3.0: ArgumentError (wrong number of arguments (given 2, expected 1))
    new_style('John', age: 10)
    # => works
    h = {age: 10}
    new_style('John', **h)
    # => works, ** is mandatory
    # The last hash argument still allowed to be passed without {}:
    old_style('John', age: 10)
    # => works
  • Notes: There is a big and detailed explanation of the separation reasons, logic, and edge cases on Ruby site, written at the dawn of 2.7, so we will not go into more details here.

Procs with “rest” arguments and keywords: change of autosplatting behavior

Just a leftover from the separation of keyword arguments.

  • Discussion: Feature #16166
  • Code:
    block = proc { |*args, **kwargs| puts "args=#{args}, kwargs=#{kwargs}"}, 2, a: true)
    # Ruby 2.7: args=[1, 2], kwargs={:a=>true} -- as expected
    # Ruby 3.0: args=[1, 2], kwargs={:a=>true} -- same, 2, {a: true})
    # Ruby 2.7:
    #  warning: Using the last argument as keyword parameters is deprecated
    #  args=[1, 2], kwargs={:a=>true} -- but extracted to keyword args nevertheless
    # Ruby 3.0:
    #  args=[1, 2, {:a=>true}], kwargs={} -- no attempt to extract hash into keywords, and no error/warning

Arguments forwarding (...) supports leading arguments

  • Reason: Argument forwarding, when introduced in 2.7, was able to forward only all-or-nothing. It turned out to be not enough. One of the important usages for leading arguments are cases like method_missing and other DSL-defined methods that need to pass to the nested method :some_symbol + all of the original arguments.
  • Discussion: Feature #16378
  • Documentation: doc/syntax/methods.rdoc (the link is to a master version, docs were merged post 3.0 release)
  • Code:
    def request(method, url, headers: {})
      puts "#{method.upcase} #{url} (headers=#{headers})"
    def get(...)
      request(:get, ...)
    get('', headers: {content_type: 'json'})
    # GET (headers={:content_type=>"json"})
    # Leading arguments may be present both in the call and in the definition:
    def logged_get(message, ...)
      puts message
    logged_get('Logging', '', headers: {content_type: 'json'})
  • Notes:
    • “all arguments splat” ... should be the last statement in the argument list (both on a declaration and a call)
    • on a method declaration, arguments before ... can only be positional (not keyword) arguments, and can’t have default values (it would be SyntaxError);
    • on a method call, arguments passed before ... can’t be keyword arguments (it would be SyntaxError);
    • make sure to check your punctuation thoroughly, because anything ... is a syntax for endless range, those constructs are valid syntax, but would do not what is expected:
      def delegates(...)
        # call without "()" -- actually parsed as (p()...)
        p ...
        # Prints nothing, but warns: warning: ... at EOL, should be parenthesized?
        # "," accidentally missing after 1, there is just one argument: 1...
        p(1 ...)
        # Prints "1..."
        p(1, ...)
        # Prints, as expected:
        #   1
        #   5

“Endless” method definition

Methods of exactly one statement now can be defined with syntax def method() = statement. (The syntax doesn’t need end, hence the “endless definition” moniker.)

  • Reason: Ruby’s ends (unlike C-like languages’ {}) are generally OK for Rubyists, but make small utility methods look more heavy than they should. For small utility methods with body containing just one short statement like:
    def available?

    …the “proper” definition might look so heavy that one will decide against it to leave the class more readable (and instead make class’ clients to just do obj.internal.empty? themselves, making it less semantical). For such cases, a shortcut one-line definition may change the perceiving of utility method creation:

    def available? = !@internal.any?
    def finished? = available? && @internal.all?(&:finished?)
    def clear = @internal.clear
  • Discussion: Feature #16746
  • Documentation: doc/syntax/methods.rdoc (the link is to a master version, docs were merged post 3.0 release)
  • Code:
    def dbg = puts("DBG: #{caller.first}")
    # Prints: DBG: test.rb:3:in `<main>'
    # The method definition supports all kinds of arguments:
    def dbg_args(a, b=1, c:, d: 6, &block) = puts("Args passed: #{[a, b, c, d,]}")
    dbg_args(0, c: 5) { 7 }
    # Prints: Args passed: [0, 1, 5, 6, 7]
    # For argument definition, () is mandatory
    def square x = x**2
    # syntax error, unexpected end-of-input -- because Ruby treats it as
    #   def square(x = x**2)
    # ...e.g. an argument with default value, referencing itself, and no method body
    # This works
    def square(x) = x**2
    square(100) # => 10000
    # To avoid confusion, defining method names like #foo= is prohibited
    class A
      # SyntaxError "setter method cannot be defined in an endless method definition":
      def attr=(val) = @attr = val
      # Other suffixes are OK:
      def attr?() = !!@attr
      def attr!() = @attr = true
    # funnily enough, operator methods are OK, including #==
    class A
      def ==(other) = true
    p == 5 # => true
    # any singular expression can be method body
    # This works:
    def read(name) =
    # Or even this, though, what's the point?..
    def weird(name) = begin
                        data =
    # inside method body, method calls without parentheses cause a syntax error:
    def foo() = puts "bar"
    #                ^ syntax error, unexpected string literal, expecting `do' or '{' or '('
    # This is due to parsing ambiguity and is aligned with some other places, like
    x = 1 + sin y
    #           ^ syntax error, unexpected tIDENTIFIER, expecting keyword_do or '{' or '('
  • Notes:
    • The initial proposal seems to be a good-natured April Fool’s joke, then everybody suddenly liked it, and, with a slight change of syntax, it was accepted;
    • Feature is marked as EXPERIMENTAL, but it does NOT produce a warning, it is deliberate, see discussion in Misc #17399.

Pattern matching

Pattern matching, introduced in 2.7, is no longer experimental. Discussion: Feature #17260.

One-line pattern matching with =>

  • Reason: This is an interesting one. Two facts were discussed between 2.7 and 3.0: the fact that in most of the other languages one-line pattern matching has a different order (<pattern> <operator> <data>) than introduced in Ruby 2.7 (<data> in <pattern>); and the idea of “rightward assignment operator” => for more natural chaining. And then, at some point, ideas converged most fruitfully.
  • Discussion: Feature #17260 (main pattern matching tracking ticket), Feature #16670 (reverse order), Feature #15921 (standalone rightward assignment operator), Feature #15799 (abandoned “pipeline operator” idea, in discussion of which “rightward assignment” was born)
  • Documentation: doc/syntax/pattern_matching.rdoc
  • Code:
    # match and unpack:
    {db: {user: 'John', role: 'admin'}} => {db: {user:, role:}}
    p [user, role] # => ["John", "admin"]
    # pattern-matching as a rightward assignment for long experessions:'test.txt')
        .first(10) => lines
    p lines # first 10 non-empty lines of the file
    # unpacking+assignment is extremely powerful:
    (1..10).to_a.shuffle => [*before, (2..4) => threshold, *after]
    # input sequence, find first entry in range 2..4, put it into `threshold`,
    # and split parts of the sequence before/after it
    p [before, threshold, after]    # your results might be different due to shuffle :)
    # => [[7, 5, 8], 3, [1, 10, 6, 9, 4, 2]]
    # The things can get really out of hand quickly: => ..9 | 18.. => non_working_hour
  • Notes:
    • Feature is marked as EXPERIMENTAL, will warn so on an attempt of usage, and may change in the future;
    • But simple assignment usage (data => variable) is not considered experimental and is here to stay;
    • One quirk that might be non-obvious: pattern matching can desconstruct-assign only to local variables, so when using => as an assignment operator, you will see those are syntax errors:
      some_statement => @x
      some_statement => obj.attr # meaning to call `obj.attr=`
      some_statement => $y # ...though maybe don't use global variables :)

in as a true/false check

After the change described above, in was reintroduced to return true/false (whether the pattern matches) instead of raising NoMatchingPatternError.

  • Reason: The new meaning allows pattern matching to be more tightly integrated with other constructs in the control flow, like iteration and regular conditions.
  • Discussion: Feature #17371
  • Documentation: doc/syntax/pattern_matching.rdoc
  • Code:
    user = {role: 'admin', login: 'matz'}
    if user in {role: 'admin', login:}
      puts "Granting admin scope: #{login}"
    # otherwise just proceed with regular scope, no need to raise
    users = [
      {name: 'John', role: 'user'},
      {name: 'Jane', registered_at:, 5, 8) },
      {name: 'Barb', role: 'admin'},
      {name: 'Dave', role: 'user'}
    old_users_range =
    # Choose for some notification only admins and old users { |u| u in {role: 'admin'} | {registered_at: ^old_users_range} }
    #=> [{:name=>"Jane", :registered_at=>2017-05-08 00:00:00 +0300}, {:name=>"Barb", :role=>"admin"}]
  • Notes:
    • Feature is marked as EXPERIMENTAL, will warn so on an attempt of usage, and may change in the future.

Find pattern

Pattern matching now supports “find patterns”, with several splats in them.

  • Discussion: Feature #16828
  • Documentation: doc/syntax/pattern_matching.rdoc
  • Code:
    users = [
      {name: 'John', role: 'user'},
      {name: 'Jane', role: 'manager'},
      {name: 'Barb', role: 'admin'},
      {name: 'Dave', role: 'manager'}
    # Now, how do you find admin with just pattern matching?..
    # Ruby 3.0:
    case users
    in [*, {name:, role: 'admin'}, *] # Note the pattern: find something in the middle, with unknown number of items before/after
      puts "Admin: #{name}"
    # => Admin: Barb
    # Without any limitations to choose the value, the first splat is non-greedy:
    case users
    in [*before, user, *after]
      puts "Before match: #{before}"
      puts "Match: #{user}"
      puts "After match: #{after}"
    # Before match: []
    # Match: {:name=>"John", :role=>"user"}
    # After match: [{:name=>"Jane", :role=>"manager"}, {:name=>"Barb", :role=>"admin"}, {:name=>"Dave", :role=>"manager"}]
    # Guard clause does not considered when choosing where to splat:
    case users
    in [*, user, *] if user[:role] == 'admin'
      puts "User: #{user}"
    # => NoMatchingPatternError -- it first put John into `user`,
    # and only then checked the guard clause, which is not matching
    # If the "find pattern" is used (there is more than one splat in the pattern),
    # there should be exactly TWO of them, and they may ONLY be the very first and
    # the very last element:
    case users
    in [first_user, *, {name:, role: 'admin'}, *]
      #                                        ^  syntax error, unexpected *
      puts "Admin: #{name}"
    # Singular splat is still allowe in any place:
    case users
    in [{name: first_user_name}, *, {name: last_user_name}]
      puts "First user: #{first_user_name}, last user: #{last_user_name}"
    # => First user: John, last user: Dave
  • Notes:
    • Feature is marked as EXPERIMENTAL, will warn so on an attempt of usage, and may change in the future.

Changes in class variable behavior

When class @@variable is overwritten by the parent of the class, or by the module included, the error is raised. In addition, top-level class variable access also raises an error.

  • Reason: Class variables, with their “non-intuitive” access rules, are frequently considered a bad practice. They still can be very useful for tracking something across class hierarchy. But the fact that the entire hierarchy shares the same variable may lead to hard-to-debug bugs, so it was fixed to raise on the usage that seems unintentional.
  • Discussion: Bug #14541
  • Documentation:
  • Code:
    # Intended usage: parent defines the variable available for all children
    class Good
      @@registry = [] # assume it is meant to store all the children
      def self.registry
    class GoodChild < Good
      def self.register!
        @@registry << self
        @@registry = @@registry.sort # reassigning the value -- but it is still the PARENT's variable
    p Good.registry # => [GoodChild]
    # Unintended usage: the variable is defined in the child, but then the parent changes it
    class Bad
      def self.corrupt_registry!
        @@registry = []
    class BadChild < Bad
      @@registry = {} # This is some variable which meant to belong to THIS class
      def self.registry
    Bad.corrupt_registry! # Probably unexpected for BadChild's author, its ancestor have changed the variable
    BadChild.registry     # On the next attempt to _access_ the variable the error will be raised
    # 2.7: => []
    # 3.0: RuntimeError (class variable @@registry of BadChild is overtaken by Bad)
    # The same error is raised if the included module suddenly changes class
    module OtherRegistry
      @@registry = {}
    Good.include OtherRegistry
    Good.registry      # On the next attempt to _access_ the variable the error will be raised
    # 2.7: => {}
    # 3.0: RuntimeError (class variable @@registry of Good is overtaken by OtherRegistry)

Other changes

  • Global variables that lost their special meaning, just a regular globals now:
  • yield in a singleton class definitions, which was deprecated in 2.7 is now SyntaxErrorFeature #15575
  • Assigning to a numbered parameter (introduced in 2.7) is now a SyntaxError instead of a warning.


The discussion of possible solutions for static or gradual typing and possible syntax of type declarations in Ruby code had been open for years. At 3.0, Ruby’s core team made their mind towards type declaration in separate files and separate tools to check types. So, as of 3.0:

  • type declarations for Ruby code should be defined in separate files with extension *.rbs
  • the syntax for type declarations is like following (small example):
    class Dog
      attr_reader name: String
      def initialize: (name: String) -> void
      def bark: (at: Person | Dog | nil) -> String
  • type declaration for the core classes and the standard library is shipped with the language;
  • rbs library is shipped with Ruby, providing tools for checking actual types in code against the declarations; and for auto-detecting (to some extent) the actual types of yet-untyped code;
  • TypeProf is another tool (“Type Profiler”) shipping with Ruby 3.0 as a bundled gem, allowing to auto-detect the actual types of the Ruby by “abstract interpretation” (running through code without actually executing it);

For a deeper understanding, please check those tools’ documentation; also this article by Vladimir Dementiev of Evil Martians takes a detailed look into tools and concepts.

Core classes and modules

Object#clone(freeze: true)

freeze: argument now works both ways (previously only freeze: false had an effect).

  • Reason: Mostly for consistency. Object#clone(freeze: false) was introduced in Ruby 2.4 as an only way of producing unfrozen object; clone(freeze: true) is basically an equivalent of clone + freeze.
  • Discussion: Feature #16175
  • Documentation: Kernel#clone
  • Code:
    o =
    o.clone(freeze: true).frozen?
    # => false in Ruby 2.7
    # => true in Ruby 3.0
    o =
    o.clone(freeze: false).frozen?
    # => false in Ruby 2.7 and 3.0

Object#clone passes freeze: argument to #initialize_clone

Specialized constructor #initialize_clone, which is called when object is cloned, now receives freeze: argument if it was passed to #clone.

  • Reason: For composite objects, it is hard to address freezing/unfreezing of nested data in #initialize_clone without this argument.
  • Discussion: Bug #14266
  • Documentation: Kernel#clone
  • Code:
    require 'set'
    set = Set[1, 2, 3].freeze
    set.frozen? # => true
    set.instance_variable_get('@hash').frozen? # => true, as expected
    unfrozen = set.clone(freeze: false)
    unfrozen.frozen? # => false, as expected
    # 2.7: => true, still
    # 3.0: => false, as it should be -- becase Set have redefined #initialize_clone
    unfrozen << 4
    # 2.7: FrozenError (can't modify frozen Hash: {1=>true, 2=>true, 3=>true})
    # 3.0: => #<Set: {1, 2, 3, 4}>
  • Notes: A lot of attention to proper object freezing in Ruby 3.0 is due to the introduction of Ractors, which made the important distinction if the object is truly frozen (and therefore safe to share between parallel ractors).

Kernel#eval changed processing of __FILE__ and __LINE__

Now, when the second argument (binding) is passed to eval, __FILE__ in evaluated code is (eval), and __LINE__ starts from 1 (just like without binding). Before 2.7 it was evaluated in context of binding (e.g. returned file from where binding came from), on 2.7 the warning was printed; now the behavior is considered final.

  • Reason: Binding might be passed to eval in order to provide the access to some context necessary for evaluation (this technique, for example, frequently used in templating engines); but it had an unintended consequence of making __FILE__ and __LINE__ to point not to actually evaluated code, which can be misleading, for example, on errors processing.
  • Discussion: Bug #4352
  • Affected methods: Kernel#eval, Binding#eval
  • Code:
    # file a.rb
    class A
      def get_binding
    # file b.rb
    require_relative 'a'
    eval('p [__FILE__, __LINE__]')                    # without binding
    eval('p [__FILE__, __LINE__]', # with binding from another file
    # Ruby 2.6:
    #  ["(eval)", 1]
    #  ["a.rb", 3]
    # Ruby 2.7:
    #  ["(eval)", 1]
    #  ["a.rb", 3]
    #   warning: __FILE__ in eval may not return location in binding; use Binding#source_location instead
    #   warning: __LINE__ in eval may not return location in binding; use Binding#source_location instead
    # Ruby 3.0:
    #  ["(eval)", 1]
    #  ["(eval)", 1]

Regexp and Range objects are frozen

  • Reason: The change is related to Ractor introduction (see below): it makes a difference whether an object is frozen when sharing it between ractors. As both ranges and regexps have immutable core data, it was considered that making them frozen is the right thing to do.
  • Discussion: Feature #8948, Feature #16377, Feature #15504
  • Code:
    /foo/.frozen?   # => true
    (42...).frozen? # => true
    # Regexps are frozen even when constructed with dynamic interpolation
    /.#{rand(10)}/.frozen? # => true
    # ...but not when they are constructed with the constructor'foo').frozen?  # => false
    # ...but ranges are always frozen'a', 'b').frozen? # => true
    # Regularly, as the data can't be changed anyways, the frozenness wouldn't affect your code.
    # It might, though, if the code does something smart like:
    regexp = /^\w+\s*\w*$/
    regexp.instance_variable_set('@context', :name)
    # 2.7: ok
    # 3.0: FrozenError (can't modify frozen Regexp: /^\w+\s*\w*$/)
    # ...or
    RANGE =, 3, 1), 9, 1)
    def RANGE.to_s
      self.begin.strftime('%Y, %b %d') + ' - ' + self.end.strftime('%b %d')
    # 2.7: OK
    # 3.0: FrozenError (can't modify frozen object: 2020-03-01 00:00:00 +0200..2020-09-01 00:00:00 +0300)
    # Note also, that range freezing is not "deep":
    string_range = 'a'..'z'
    # => 'a'..'Z'
    # clone(freeze: false) still allows to unfreeze both:
    unfrozen = RANGE.clone(freeze: false)
    def unfrozen.to_s
      self.begin.strftime('%Y, %b %d') + ' - ' + self.end.strftime('%b %d')
    puts unfrozen
    # Prints: "2020, Mar 01 - Sep 01"
  • Notes: The fact that dynamically created ranges are frozen and regexps are not, is explained this way: Ideally, both should be frozen, yet there are some gems (and some core Ruby tests) that use dynamic singleton method definitions on regexps, and it was decided that incompatibility should be avoided.


#include and #prepend now affects modules including the receiver

If class C includes module M, and after that we did M.include M1, before Ruby 3.0, M1 would not have been included into C; and now it is.

  • Reason: The behavior was long-standing and led to a lot of confusion (especially considering that including something in the class did affected its descendants).
  • Discussion: Feature #9573
  • Documentation:
  • Code:
    module MyEnumerableExtension
      def each2(&block)
        each_slice(2, &block)
    Enumerable.include MyEnumerableExtension
    # Ruby 2.7: NoMethodError (undefined method `each2' for 1..8:Range) -- even though Range includes Enumerable
    # Ruby 3.0: [[1, 2], [3, 4], [5, 6], [7, 8]]

Improved method visibility declaration

private attr_reader :a, :b, :c and private alias_method :foo, :bar now work exactly like one might expect.

  • Changed behaviors:
    • Module#public, #private, #protected can now accept an array. While it is not necessary when a literal list of symbols is passed, it is more convenient when the method names are calculated by some DSL method.
    • Module#attr_accessor, #attr_reader, #attr_writer now return arrays of symbols with new methods names defined.
    • Module#alias_method now returns a name of the method defined.
  • Documentation:
  • Discussion: Feature #17314
  • Code:
    class A
      def foo
      def bar
      # new behavior of private
      private %i[foo bar]
      # Ruby 2.7: [:foo, :bar] is not a symbol nor a string
      # Ruby 3.0: works
      # old behavior still works
      private :foo, :bar
      # new behavior of attr_XX
      p(attr_reader :a, :b)
      # Ruby 2.7: => nil
      # Ruby 3.0: => [:a, :b]
      # new behavior of alias_method
      p(alias_method :baz, :foo)
      # Ruby 2.7: => A
      # Ruby 3.0: => :bar
      # The consequence is this is now possible:
      private attr_reader :first, :second
      # attr_reader() returns array [:first, :second], which is then passed to private()
      private alias_method :third, :second
      # alias_method() returns :third, which is then passed to private()
  • Note: Unlike alias_method, alias is not a method but a language construct, and its behavior haven’t changed (and it can’t be used in an expression context):
      class A
        def foo
        private alias bar foo
        #       ^ syntax error, unexpected `alias'

Warning#warn: category: keyword argument.

Ruby 2.7 introduced warning categories, and allowed to selectively suppress them with Warning[]= method. Since Ruby 3.0, user code also may specify categories for its warnings, and intercept them by redefining Warning.warn.

  • Discussion: Feature #17122
  • Documentation: Warning#warn, Kernel#warn
  • Code:
    # Using from user code:
    Warning[:deprecated] = true
    warn('my warning', category: :deprecated)
    # "my warning"
    Warning[:deprecated] = false
    warn('my warning', category: :deprecated)
    # ...nothing, obeys "don't show deprecated" setting
    # If the category is not supported:
    warn('my warning', category: :custom)
    # ArgumentError (invalid warning category used: custom)
    # Intercepting:
    module Warning
      def self.warn(msg, category: nil)
        puts "Received message #{msg.strip} with category=#{category}"
    Warning[:deprecated] = true
    # Received message 'warn.rb:23: warning: lambda without a literal block is deprecated; use the proc without lambda instead' with category=deprecated
    eval('[1, 2, 3] => [x, *]') # we use eval, otherwise the warning was raised on PARSING stage, before any redefinitions
    # Received message '(eval):1: warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!' with category=experimental

Exception output order is changed – again

The “reverse” order of the backtrace (call stack printed starting from the outermost layer, and the innermost is at the bottom), which was introduced in 2.5 as “experimental”, tweaked to be active only for STDERR on 2.6, now it is reverted to the old (pre-2.5) behavior.

  • Reason: “From the outer to the inner” order is the default for some other languages (like Python), but it seems most of the rubyists never got used to it.
  • Discussion: Feature #8661 (the whole history of the “backtrace reversing” initiative, with all the turns)
  • Code:
    def inner
      raise 'test'
    def outer

    This prints on Ruby 2.5-2.7:

    Traceback (most recent call last):
      2: from test.rb:9:in `<main>'
      1: from test.rb:6:in `outer'
    test.rb:2:in `inner': test (RuntimeError)

    But on Ruby < 2.5, and on 3.0 again:

    test.rb:2:in `inner': test (RuntimeError)
      from test.rb:6:in `outer'
      from test.rb:9:in `<main>'
  • Notes:
    • While redesigning the backtrace, command-line option --backtrace-limit=<number-of-entries> was introduced, so, you can run the example above this way to partially suppress backtrace:
      $ ruby --backtrace-limit=0 test.rb
      test.rb:2:in `inner': test (RuntimeError)
         ... 2 levels...

Strings and symbols

Interpolated String literals are no longer frozen when # frozen-string-literal: true is used

  • Reason: The idea of frozen-string-literal pragma is to avoid unnecessary allocations, when the same "string" repeated in the code called multiple times; but if the string is constructed dynamically with interpolation, there would be no point (it is hard to predict that allocations would be avoided, as the string can be different each time); it also breaks the “intuitive” feeling that the string is dynamic.
  • Discussion: Feature #17104
  • Documentation: doc/syntax/comments.rdoc
  • Code:
    # frozen-string-literal: true
    def render(title, body)
      result = "## #{title}"
      result << "\n"
      result << body
    puts render('The Title', 'The body')
    # Ruby 2.7: in `render': can't modify frozen String (FrozenError)
    # Ruby 3.0:
    #   ## The Title
    #   The body

String: always returning String

On custom classes inherited from String, some methods previously were returning an instance of this class, and others returned String. Now they all do the latter.

  • Reason: While one might argue that" foo ").strip should return an instance of MyCoolString, it actually creates a significant problem: String internals don’t know how to construct a new instance of the child class (imagine it has a constructor like, filename:)), and in Ruby versions before 3.0 they didn’t actually construct child classes with the constructor. It was a source of all kinds of subtle bugs.
  • Discussion: Bug #10845
  • Affected methods: #*, #capitalize, #center, #chomp, #chop, #delete, #delete_prefix, #delete_suffix, #downcase, #dump, #each_char, #each_grapheme_cluster, #each_line, #gsub, #ljust, #lstrip, #partition, #reverse, #rjust, #rpartition, #rstrip, #scrub, #slice!, #slice / #[], #split, #squeeze, #strip, #sub, #succ / #next, #swapcase, #tr, #tr_s, #upcase
  • Code:
    class Buffer < String
      attr_reader :capacity
      def initialize(content, capacity:)
        @capacity = capacity
      # some impl.
    stripped =' foo ', capacity: 100).strip
    # Ruby 2.7: Buffer
    # Ruby 3.0: String
    # Ruby 2.7: nil -- one might ask "why it haven't been copied from parent???"
    # Ruby 3.0: NoMethodError (undefined method `capacity' for "foo":String)
  • Note: The same change was implemented for Array.


The method returns a frozen string with the symbol’s representation.

  • Reason: Producing non-frozen strings from Symbol#to_s might lead to a memory/performance overhead, for example, when large data structures are serialized. It was discussed that #to_s should be changed to just return a frozen String, but it would create a backward compatibility problem in code looking like this:
    # Not the best, but existing in the wild way of constructing strings from data containing symbols
    {some: 'data'}.map { |k, v| k.to_s << ': ' << v }.join(', ')
    # If k.to_s returns a frozen string, it would throw FrozenError (can't modify frozen String: "some")

    as a compromise, a new method was added.

  • Discussion: Feature #16150
  • Documentation: Symbol#name


Array: always returning Array

On custom classes inherited from Array, some methods previously were returning an instance of this class, and others returned Array. Now they all do the latter.

  • Reason: See above, where the same changes for String are explained.
  • Discussion: Bug #6087
  • Affected methods: #*, #drop, #drop_while, #flatten, #slice!, #slice / #[], #take, #take_while, #uniq
  • Code:
    class Nodes < Array
      attr_reader :parent
      def initialize(nodes, parent)
        @parent = parent
      # some impl.
    uniq =['<tr>Name</tr>', '<tr>Position</tr>', '<tr>Name</tr>'], 'table').uniq
    # Ruby 2.7: Nodes
    # Ruby 3.0: Array
    # Ruby 2.7: nil -- one might ask "why it haven't been copied from parent???"
    # Ruby 3.0: NoMethodError (undefined method `uniq' for ["<tr>Name</tr>", "<tr>Position</tr>"]:Array)

Array: slicing with Enumerator::ArithmeticSequence

Enumerator::ArithmeticSequence was introduced in 2.6 as an concept representing “sequence from b to e with step s”. Since 3.0, Array#[] and Array#slice accept arithmetic sequence as a slicing argument.

  • Discussion: Feature #16812
  • Documentation: Array#[]
  • Code:
    text_data = ['---', 'Jane', '---', 'John', '---', 'Yatsuke', '---']
    text_data[(1..) % 2] # each second element
    # => ["Jane", "John", "Yatsuke"]
    # Note that unlike slicing with Range, slicing with ArithmeticSequence might raise RangeError:
    # => ["---", "Jane", "---", "John", "---", "Yatsuke", "---"]
    text_data[(0..100) % 2]
    # RangeError (((0..100).%(2)) out of range)
  • Notes: Don’t forget to put () around range: without them, 0..100 % 2 is actually (0)..(100 % 2), e.g. 0..0.


  • Reason: Hash#slice (“only selected set of keys”) was added in Ruby 2.5, but Hash#except was missing at this point. Added for completeness, and because it has lot of pragmatic usages.
  • Discussion: Feature #15822
  • Documentation: Hash#except, ENV.except
  • Code:
    h = {a: 1, b: 2}
    # => {:a=>1}
    h.except(:c)        # unknown key is not an error
    # => {:a=>1, :b=>2}
  • Note: Unlike ActiveSupport, there’s no Hash#except! (like there is no #slice!)

Hash#transform_keys: argument for key renaming

Hash#transform_keys(from: to) allows to rename keys in a DRY way.

  • Discussion: Feature #16274
  • Documentation: Hash#transform_keys
  • Code:
    h = {name: 'Ruby', years: 25}
    h.transform_keys(name: :title, years: :age)
    # => {:title=>"Ruby", :age=>25}
    h.transform_keys(name: :title, site: :url)
    # => {:title=>"Ruby", :years=>25} -- not mentioned keys are copied as is, unknown keys ignored
    h.transform_keys(years: :name, name: :title)
    # => {:title=>"Ruby", :name=>25} -- note that the first rename wouldn't replace the :name key, because
    #                                   first all renames are deduced, and then applied
    h.transform_keys(name: :lang_name) { |k| :"meta_#{k}" }
    # => {:lang_name=>"Ruby", :meta_years=>25} -- block, if passed, is used to process keys the hash doesn't mention
    h.transform_keys!(name: :title, years: :age) # bang version works, too
    # => {:title=>"Ruby", :age=>25}

Hash#each consistently yields a 2-element array to lambdas

Just fixes an inconsistency introduced by optimization many versions ago.

  • Discussion: Bug #12706
  • Code:
    {a: 1}.each(&->(pair) { puts "pair=#{pair}" })
    # 2.7 and 3.0: pair=[:a, 1] -- so, it is "yield 2 items as one argument"
    {a: 1}.each(&->(pair, redundant) { puts "pair=#{pair}, redudnant=#{redundant}" })
    # 2.7: pair=a, redudnant=1 -- arguments accidentally behave like for regular proc (auto-unpacking)
    # 3.0: ArgumentError (wrong number of arguments (given 1, expected 2)) -- no unexpected auto-unpacking


Symbol#to_proc reported as lambda

  • Reason: It was noted that the result of &:something behaves like a lambda, but its introspection methods are misleadingly reported it as a regular proc. While no big offense, it can be inconvenient when learning, and produce subtle bugs in complicated metaprogramming.
  • Discussion: Feature #16260
  • Documentation:
  • Code:
    def test_it(&block)
      p [block.lambda?, block.parameters]
    # Regular proc
    test_it { |x| x.size }      # => [false, [[:opt, :x]]]
    # Regular lambda
    test_it(&->(x) { x.size })  # => [true, [[:req, :x]]]
    # Symbol proc
    # Ruby 2.7: [false, [[:rest]]]
    # Ruby 3.0: [true, [[:req], [:rest]]]

    The second one is more true, because it behaves like lambda: doesn’t auto-unpack parameters, and requires the first one:

    Warning[:deprecated] = true
    proc { |x, y| x + y }.call([1, 2])    # => 3, parameters unpacked
    proc { |x| x.inspect }.call           # => "nil", no error, parameter is optional
    lambda { |x, y| x + y }.call([1, 2])  # ArgumentError (wrong number of arguments (given 1, expected 2))
    lambda { |x| x.inspect }.call         # ArgumentError (wrong number of arguments (given 0, expected 1))[1, 2])               # ArgumentError (wrong number of arguments (given 0, expected 1))                 # ArgumentError (no receiver given)

    Note that the last two errors was raised even on Ruby 2.7, so the behavior is not changed, just introspection made consistent.

Kernel#lambda warns if called without a literal block

  • Reason: One might expect that lambda(&other_block) will change block’s “lambdiness”. It is not true, and it was decided that this behavior shouldn’t change. Instead, to avoid errors, this code now warns.
  • Discussion: Feature #15973
  • Documentation:
  • Code:
    block = proc { |x| p x }
    processed = lambda(&block)
    # On 3.0: warning: lambda without a literal block is deprecated; use the proc without lambda instead
    processed.lambda? # => false    # => "nil", it is really still not a lambda

Proc#== and #eql?

The method will return true for separate Proc instances if the procs were created from the same block.

  • Reason: Lazy Proc allocation optimization (not creating Proc object while just propagating &block further) introduced an inconvenience for some DSL libraries when what are “logically” two references to the same proc, would become two unrelated objects, which could lead to subtle bugs.
  • Discussion: Feature #14267
  • Documentation: Proc#==
  • Code:
    class SomeDSL
      attr_reader :before_block, :after_block
      def before(&block)
        @before_block = block
      def after(&block)
        @after_block = block
      def before_and_after(&block)
      def before_and_after_are_same?
        @before_block == @after_block
    dsl =
    dsl.before_and_after { 'some code' }
    dsl.before_block.object_id == dsl.after_block.object_id
    # => true on Ruby 2.4: they were converted to Proc object in #before_and_after, and then this
    #    object was propagated to #before/#after
    # => false on Ruby 2.5-3.0: block was propagated without converting to object, and converts to
    #    two different objects in #before/#after while storing in variables
    p dsl.before_and_after_are_same?
    # => true on Ruby 2.4, because it is the same object
    # => false on Ruby 2.5-2.7
    # => true on 3.0: the objects ARE different, but == implemented the way that they are equal if
    #    they are produced from exactly the same block

Random::DEFAULT behavior change

Random::DEFAULT was an instance of the Random class, used by Random.rand/Kernel.rand. The object was removed, and now Random::DEFAULT is just an alias for Random class itself (it has class-level methods same as instance-level methods), and Random.rand creates a new random generator implicitly per ractor, on the first call in that ractor.

  • Reason: With the introduction of the Ractors, global mutable objects can’t be used, and Random::DEFAULT was such object (its state have changed with each #rand call); so now the default random generator is ractor-local, and is not exposed as a constant (it is decided that it would be too confusing to have a constant with the value different in each ractor).
  • Discussion: Feature #17322 (Random::DEFAULT becomes the synonym of Random), Feature #17351 (deprecating the constant).
  • Documentation: Random
  • Code:
    Warning[:deprecated] = true
    # Ruby 2.7: => #<Random:0x005587d09fba20>
    # Ruby 3.0:
    #   warning: constant Random::DEFAULT is deprecated
    #   => Random -- just the same class
    # Ruby 2.7: NoMethodError: undefined method `seed' for Random:Class
    # Ruby 3.0: => 77406376310392739943146667089721213130
    # Seed is delegated to (now internal) per-Ractor random object, which is always the same in the same ractor { Random.seed }.uniq
    # => [77406376310392739943146667089721213130]
    # It is different in another ractor { p Random.seed }
    # => 95097031741178961937025250800360539515
  • Notes:
    • See Ractors explanation below, and Ractor class docs for deeper understanding of ractors data sharing model.

Filesystem and IO

Dir.glob and Dir.[] result sorting

By default the results are sorted, which can be turned off with sorted: false

  • Reason: Dir.glob historically returns the results in the same order the underlying OS API does it (which is undefined on Linux). While it can be argued as “natural” (do what the OS does), it is inconvenient for most of the cases; and most of other languages and tools switched to sorting in the last years.
  • Discussion: Feature #8709
  • Documentation: Dir.glob
  • Note: Dir.glob('pattern', sort: false) allows to return results in old (system) order; this might be useful when testing some OS behavior.



A long-anticipated concurrency improvement landed in Ruby 3.0. Ractors (at some point known as Guilds) are fully-isolated (without sharing GVL on CRuby) alternative to threads. To achieve thread-safety without global locking, ractors, in general, can’t access each other’s (or main program/main ractor) data. To share data with each other, ractors rely on message passing/receiving mechanism, aka actor model (hence the name). The feature was in design and implementation for a long time, so we’ll not try to discuss it here in details or link to some “main” discussion, just provide a simple example and links for further investigation.

  • Code: The simplistic example of ractors in action (classic “server-client” ping-pong):
    server = do
      puts "Server starts: #{self.inspect}"
      puts "Server sends: ping"
      Ractor.yield 'ping'                       # The server doesn't know the receiver and sends to whoever interested
      received = Ractor.receive                 # The server doesn't know the sender and receives from whoever sent
      puts "Server received: #{received}"
    client = do |srv|        # The server ractor is passed to client, and available as srv
      puts "Client starts: #{self.inspect}"
      received = srv.take                       # The Client takes a message specifically from the server
      puts "Client received from " \
           "#{srv.inspect}: #{received}"
      puts "Client sends to " \
           "#{srv.inspect}: pong"
      srv.send 'pong'                           # The client sends a message specifically to the server
    [client, server].each(&:take)               # Wait till they both finish

    This will output:

    Server starts: #<Ractor:#2 test.rb:1 running>
    Server sends: ping
    Client starts: #<Ractor:#3 test.rb:8 running>
    Client received from #<Ractor:#2 rac.rb:1 blocking>: ping
    Client sends to #<Ractor:#2 rac.rb:1 blocking>: pong
    Server received: pong
  • Documentation: Ractor (class documentation), doc/ (design documentation).

Non-blocking Fiber and scheduler

The improvement of inter-thread concurrency for IO-heavy tasks is achieved with non-blocking Fibers. When several long I/O operations should be performed, they can be put in separate non-blocking Fibers, and instead of blocking each other while waiting, they would be transferring the control to the fiber that can proceed, while others wait.

  • Discussion: Feature #16786, Feature #16792 (Mutex belonging to Fiber)
  • New and updated methods:
    • Fiber: Fiber.set_scheduler, Fiber.scheduler, (blocking: true/false) parameter, Fiber#blocking?, Fiber.blocking?
    • Methods invoking scheduler: Kernel.sleep, IO#wait_readable, IO#wait_writable, IO#read, IO#write and other related methods (e.g. IO#puts, IO#gets), Thread#join, ConditionVariable#wait, Queue#pop, SizedQueue#push;
    • IO#nonblock? now defaults to true;
    • Mutex belongs to Fiber rather than Thread (can be unlocked from other fiber than the one that locked it).
  • Documentation: Fiber (class documentation), Fiber::SchedulerInterface (definition of the interface which the scheduler must implement, doc/ (design documentation).
  • Code:
    require 'net/http'
    start = do # in this thread, we'll have non-blocking fibers
      Fiber.set_scheduler # see Notes about the scheduler implementation
      %w[2.6 2.7 3.0].each do |version|
        Fiber.schedule do # Runs block of code in a separate Fiber
          t =
          # Instead of blocking while the response will be ready, the Fiber will invoke scheduler
          # to add itself to the list of waiting fibers and transfer control to other fibers
          Net::HTTP.get('', "/rubychanges/#{version}.html")
          puts '%s: finished in %.3f' % [version, - t]
    end.join # At the END of the thread code, Scheduler will be called to dispatch all waiting fibers
             # in a non-blocking manner
    puts 'Total: finished in %.3f' % ( - start)
    # Prints:
    #  2.6: finished in 0.139
    #  2.7: finished in 0.141
    #  3.0: finished in 0.143
    #  Total: finished in 0.146

    Note that “total” is lower than sum of all fibers execution time: on HTTP.get, instead of blocking the whole thread, they were transferring control while waiting, and all three waits are performed in parallel.

  • Notes: The feature is somewhat unprecedented for Ruby in the fact that no default Scheduler implementation is provided. Implementing the Scheduler in a reliable way (probably using some additional non-blocking event loop library) is completely up to the user. Considering that the feature is implemented by Samuel Williams of the Async fame, the Async gem utilizes the new feature since the day of 3.0 release.

Thread.ignore_deadlock accessor

Disabling the default deadlock detection, allowing the use of signal handlers to break deadlock.

  • Reason: The change helps with rare condition when Ruby’s internal deadlock detector is fooled by external signals.
  • Discussion: Bug #13768
  • Documentation: Thread.ignore_deadlock=, Thread.ignore_deadlock
  • Code:
    queue1 =
    queue2 =
    trap(:SIGCHLD) { queue1.push 'from SIGCHLD to queue1' }
    Thread.start { Process.spawn("/bin/sleep 1") }
    Thread.start { queue2.push("via Thread to queue2: #{queue1.pop}") }
    Thread.ignore_deadlock = true # <== New feature
    # Here the message would be received when the childprocess will finish, send message to queue1,
    # which then will be caught in the thread and sent to queue2.
    puts queue2.pop
    # Prints: "via Thread to queue2: from SIGCHLD to queue1"
    # But Without the marked line, it will be printed instead:
    #  in `pop': No live threads left. Deadlock? (fatal)
    #  ... description of sleeping threads ...

Fiber#backtrace & #backtrace_locations

Like similar methods of the Thread, provides a locations of the currently executed Fiber code.

  • Discussion: Feature #16815
  • Documentation: Fiber#backtrace, Fiber#backtrace_locations,
  • Code:
    f = { Fiber.yield }
    # When fiber is not yet running, the backtrace is empty
    # => []
    # When fiber was resumed, and then yielded control, you can ask about its location
    # => ["test.rb:1:in `yield'", "fbr.rb:1:in `block in <main>'"]
    # => ["fbr.rb:1:in `yield'", "fbr.rb:1:in `block in <main>'"]
    # Despite looking the same, backtrace_locations are actually instances of Thread::Backtrace::Location
    loc = f.backtrace_locations.first
    loc.label   # => "yield"
    loc.path    # => "test.rb"
    loc.line    # => 1
    # Like Thread.backtrace_locations, the method accepts arguments:
    f.backtrace_locations(1) # start from 1
    # => ["fbr.rb:1:in `block in <main>'"]
    f.backtrace_locations(0, 1) # start from 0, take 1
    # => ["test.rb:1:in `yield'"]
    f.backtrace_locations(0...1) # ranges are acceptable, too
    # => ["test.rb:1:in `yield'"]
    # When the fiber is finished, there is no location
    # => nil

Fiber#transfer limitations changed

#transfer is the method that allows fiber to pass control to other fiber directly, allowing several fibers to control each other, creating arbitrary directed graph of control. Two styles of passing control (Fiber.yield / Fiber#resume vs Fiber#transfer to and from fiber) can’t be freely mixed: the way in which fiber lost control should be the same it received the control back. In 2.7, this was implemented by a strict rule: once the fiber received control via #transfer, it can never return back to .yield/#resume style. But in 3.0, better grained set of limitations was designed, so when the Fiber passed control via #transfer and then received it back (via #transfer in other fiber), it again can .yield and be #resumed.

  • Reason: The new design makes the “entry point” fiber of #transfer graph accessible from outside the graph.
  • Discussion: Bug #17221
  • Documentation: Fiber#transfer
  • Code:
    require 'fiber'
    manager = nil # For local var to be visible inside worker block
    # This fiber would be started with transfer
    # It can't yield, and can't be resumed
    worker = { |work|
      puts "Worker: starts"
      puts "Worker: Performed #{work.inspect}, transferring back"
      # Fiber.yield     # this would raise FiberError: attempt to yield on a not resumed fiber
      # manager.resume  # this would raise FiberError: attempt to resume a resumed fiber (double resume)
    # This fiber would be started with resume
    # It can yield or transfer, and can be transferred
    # back or resumed
    manager = {
      puts "Manager: starts"
      puts "Manager: transferring 'something' to worker"
      result = worker.transfer('something')
      puts "Manager: worker returned #{result.inspect}"
      # worker.resume    # this would raise FiberError: attempt to resume a transferring fiber
      Fiber.yield        # this is OK __since 3.0__, the fiber transferred back and from, now it can yield
      puts "Manager: finished"
    puts "Starting the manager"
    puts "Resuming the manager"
    # manager.transfer  # this would raise FiberError: attempt to transfer to a yielding fiber
    manager.resume # This is possible __since 3.0__

    This prints:

    Starting the manager
    Manager: starts
    Manager: transferring 'something' to worker
    Worker: starts
    Worker: Performed "something", transferring back
    Manager: worker returned "Something"
    Resuming the manager
    Manager: finished

    Before Ruby 3.0, manager Fiber (once it ran the loop of transfers with worker), had no way to return control generically, to whatever other Fiber wants to run.

  • Notes: The full list of the new rules are:
    • Can’t transfer to the fiber resumed (e.g. at currently running due to receiving control via resume, and not transfer)
    • Can’t transfer to the fiber yielding (e.g. the one that run Fiber.yield and is waiting to be resumed)
    • Can’t resume the fiber transferred (e.g. once the fiber transfer to some other fiber, it can obtain the control back only via transfer)
    • Can’t Fiber.yield from the fiber that was never resumed (e.g. that was started due to transfer)


GC.auto_compact accessor

Setter/getter for the option to run GC.compact (introduced in Ruby 2.7) on each major garbage collection. false by default.

  • Discussion: Feature #17176
  • Documentation: GC.auto_compact, GC.auto_compact=
  • Notes: It is noticed by feature authors that currently compaction adds a significant overhead to the garbage collection, so should be tested if that’s what you want.

Standard library

  • Set: as parts of ongoing “Sets: need ♥️” initiative (Feature #16989), some adjustments were made:
    • SortedSet has been removed for dependency and performance reasons (it silently depended upon rbtree gem).
    • Set#join is added as a shorthand for .to_a.join. Discussion: Feature #16991.
    • Set#<=>: returns -1/+1 if one set is the proper sub-/super-set of the other, 0 if they are equal, and nil otherwise:
      Set[1, 2, 3] <=> Set[2, 3] # => 1
      Set[1, 2, 3] <=> Set[3, 4] # => nil
      # Note that atomic comparison operators already worked in 2.7 and earlier:
      Set[1, 2, 3] > Set[2, 3] # => true
      # But the new operator allows, for example, sorting of sub/super-sets:
      [Set[1, 2, 3], Set[1, 2]].sort
      # 2.7: ArgumentError (comparison of Set with Set failed)
      # 3.0: => [#<Set: {1, 2}>, #<Set: {1, 2, 3}>]

      Discussion: Feature #16995.

  • Libraries switched to version numbering corresponding to Ruby version (3.0.0) and made Ractor-compatible internally:
  • Libraries made Ractor-compatible with no version numbering logic change:
  • OpenStruct:
    • Made Ractor-compatible;
    • Several robustness improvements (initialization, allowed method names limitations, etc.), see discussions in Bug #12136, Bug #15409, Bug #8382.
    • Despite the improvements, use of the library is officially discouraged due to performance, version compatibility, and potential security issues.


Large updated libraries

Standard library contents change

New libraries

Libraries related to type annotations:

Libraries promoted to default gems project has a nice explanations of default and bundled gems concepts, as well as a list of currently gemified libraries.

“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.

Libraries extracted in 3.0:

Libraries excluded from the standard library

Unsupported and lesser used libraries removed from the standard library, and now can be installed as a separate gems.

  • net-telnet (was a bundled gem)
  • xmlrpc (was a bundled gem)
  • SDBM. Discussion: Bug #8446
  • WEBrick. While having a simple webserver in the standard distribution can be considered a good thing (one can show “…and start a web server…” in some very basic Ruby tutorial), WEBrick was a huge maintenance burden, mainly due to the potential security issues. While nobody is advised to use WEBrick in production, any public report about its security issue was registered as a security issue in Ruby, requiring immediate attention to maintain Ruby’s reputation. And with RubyGems system in place, any tutorial as easy might advise to install any server it considers suitable. Discussion: Feature #17303.