The letter A styled as Alchemists logo. lchemists
Published December 25, 2023 Updated January 9, 2024
Ruby 3.3.0

Once again, a new release of Ruby has arrived along with a few enhancements over last year’s 3.2.0 release. The following is a capture of some of the highlights but definitely dig into the release notes for complete details. Enjoy!


As with last year, additional speed improvements have been made with YJIT. We now get a ~15% performance boost, reduced memory usage, and faster warm up over what was seen with 3.2.0. I’ve been using YJIT in production for the past year and looking forward to making use of the latest improvements in the New Year.

We also have RubyVM::YJIT.enable which allows us to programmatically enable YJIT support. This is handy when used with the --disable-yjit and --disable-rjit command line flags to launch your application without JIT support and then enable after to speed up the boot process.

For additional reading, check out the following:


With this version of Ruby, the Prism gem is now part of Ruby core which provides the following benefits:

  • A modern Ruby syntax parser.

  • More fault tolerant and a replacement to Ripper.

  • Improved API.

For a deeper dive, check out Kevin Newton’s Advent of Prism series of articles.

💡 Speaking of parsers, the Lrama parser has officially replaced the Bison parser as used in older versions of Ruby.

M:N Thread Scheduler

A new M:N Thread Scheduler has been added for improving threads and/or Ractor performance. This can be broken down as follows:

  • M: The number of ractors.

  • N: The number of native threads.

To quote directly from the original issue, here are the advantages and disadvantages:

  • Advantages

    • We can run M ractors on N native threads simultaneously if the machine has N cores.

    • We can make huge number of Ruby threads or Ractors because we don’t need huge number of native threads.

    • We can support unmanaged blocking operations by locking a native thread to a Ruby thread which issues an unmanaged blocking operation.

    • We can make our own Ruby threads or Ractors scheduler instead of the native thread (OS) scheduler.

  • Disadvantages

    • Complicated implementation and it can be hard.

    • Can introduce incompatibility especially on TLS (Thread local storage).

    • We need to maintain our own scheduler.


IRB has gained several improvements and tighter integration with the Reline and Debug gems. For a deeper dive, check out my article on Interactive Ruby.

Performance Warnings

There is now a new warning category for performance related concerns. Example:

warn "A performance example.", category: :performance

This category is meant to warn folks of potential performance concerns for code that isn’t fully optimized. For more on this new category and warnings in general, check out my Ruby Warnings article.


Reline, as mentioned in the IRB sections above, is a pure Ruby implementation which has replaced the readline native extension as wrapped by the Readline gem.


The following are deprecated in favor of using IO.popen:

  • `Kernel#open

  • `

  • `IO.binread

  • `IO.foreach

  • `IO.readlines

  • `

  • `IO.write

For example, the following is not recommended:

open("| pbcopy", "w") { |clipboard| clipboard.write "demo" }
# warning: Calling Kernel#open is deprecated and will be removed in Ruby 4.0; use IO.popen instead

Instead, you can use .popen without getting warnings:

IO.popen("pbcopy", "w") { |clipboard| clipboard.write "demo" }

Anonymous Parameters

In an unfortunate turn of events — as documented in this issue — anonymous parameters are no longer forwardable to closures within the same method. For example, the following use to work but is now a SyntaxError with this version:

def demo(*) = -> * { puts(*) }
# anonymous rest parameter is also used within block (SyntaxError)

def demo(**) = -> * { puts(**) }
# anonymous keyword rest parameter is also used within block (SyntaxError)

def demo(&) = -> * { puts(&) }
# anonymous block parameter is also used within block (SyntaxError)

💡 For a deeper dive on parameters, check out my article on Method Parameters And Arguments.

Keyword Arguments

You’ll now get a warning when overwriting a previously provided keyword argument. Example:

def demo(label:, content:) = puts "#{label}: #{content}."

demo label: "Demo", content: "A demonstration", label: "Overwrite"
# warning: key :label is duplicated and overwritten on line 2
# Overwrite: A demonstration.

The same applies when splatting keyword arguments:

demo label: "Demo", content: "A demonstration", **{label: "Overwrite"}
# warning: key :label is duplicated and overwritten on line 2
# Overwrite: A demonstration.

In previous versions of Ruby, the original "Demo" value would be silently overwritten with "Overwrite" for label but now you’ll get a proper warning.


The following environment variables have been introduced:

  • RUBY_GC_HEAP_{0,1,2,3,4}_INIT_SLOTS: Replaces the deprecated RUBY_GC_HEAP_INIT_SLOTS variable for configuring Garbage Collection (GC) heap slots.

  • REMEMBERED_WB_UNPROTECTED_OBJECTS_LIMIT_RATIO: Reduces triggering the GC in order to improve application performance since the more GCs you have, the slower your application is.

  • RUBY_MN_THREADS: Allows you to enable M:N threads for the main Ractor. Use 1 to enable.

  • RUBY_MAX_CPU: Allows you to specify the maximum number of native threads for the M:N scheduler. Default: 8.

  • RUBY_CRASH_REPORT: Allows you to redirect crash reports to a file or subcommand.


The following breaks down changes to Ruby core syntax:


The defined? keyword is now optimized for Object Shapes as first mentioned in 3.2.0. For more on keywords, check out my article on Ruby Keywords.


Use of Numbered Parameters has been part of Ruby Antipatterns ever since they were introduced in Ruby 2.7.0. With this Ruby release, use of it is being introduced — only as a warning — with full support to be added in Ruby 3.4.0. Until Ruby 3.4.0 is released, use of it without a receiver, argument, or block will issue a warning. Example:

def it = "demo"

def call(&block) = puts yield

call { it }

# warning: `it` calls without arguments will refer to the first block param in Ruby 3.4; use it() or

The reason call { it } is now a warning is because in Ruby 3.4.0, it will be an alias to the _1 numbered parameter. The warning can be corrected, per the warning suggestion, as follows:

# None of these will issue a warning.
call { it() }
call { }

For RSpec, this won’t effect your specifications unless you have a spec defined where it takes no arguments. Otherwise, you’re existing specs will remain unaffected.


As with defined?, mentioned above, the following method are optimized: #block_given?, #is_a?, and #instance_of?.

Pay special attention to #is_a? since, with this release, the latest optimization far surpasses the equivalent Ruby Pattern Matching (example): object in Hash. So stick with #is_a? to keep your code performant.


This method now accepts symbolize_names: true as a keyword argument — which, unfortunately, introduces more Boolean Parameter Control Coupling — that will automatically convert keys as symbols instead of keys as strings (default). Example:

demo = "demo_example"
pattern = /(?<prefix>\w+)_(?<suffix>\w+)/

# {
#   "prefix" => "demo",
#   "suffix" => "example"
# }

demo.match(pattern).named_captures symbolize_names: true
# {
#   prefix: "demo",
#   suffix: "example"
# }


This method allows you to set a temporary name, for debugging purposes, which does not change the permanent name of the module or pollute the global namespace with a constant that might conflict with existing constants. By default, the name of an anonymous class/module is always nil:

demo =

demo       # #<Module:0x00000001241bbe10>  # nil

Now, with #set_temporary_name, you can provide a temporary name to improve the readability/understandability of your anonymous class/module:

demo =
demo.set_temporary_name "other"

demo      # "other" # "other"

Keep in mind that attempting to set a temporary name for a permanent module will result in an error, though:

Demo =
Demo.set_temporary_name "Other"

# `set_temporary_name': can't change permanent name (RuntimeError)


With ranges, we now have a way to check if an existing range overlaps another range makes simplifies having to use a combination of #cover? and #== to pull this off. Example:

(1..3).overlap? -3..          # true
(1..3).overlap? 2..4          # true
(1..3).overlap? 3..           # true
(1..3).overlap?(..0)          # false
(1..3).overlap? 4..           # false
(1..3).overlap? 5             # Integer (expected Range) (TypeError)
(1..3).overlap? nil           # NilClass (expected Range) (TypeError)
("a".."z").overlap? "e".."m"  # true


In previous versions of Ruby, you could not message #reverse_each on beginning endless range but now you can. Example:

(..5).reverse_each.take 3  # [5, 4, 3]


range = 1..5.5r

range       # [1, 2, 3, 4, 5]
range.size  # 6

The bug, in Ruby 3.2.0 and earlier, was that range.size would report 5 instead of 6 which didn’t account for the Rational endpoint.


Use of Refinement#refined_class has been deprecated in favor of Refinement#target. For more on refinements in general, check out my Ruby Refinements article.

Special Mentions

The following are not in this version release but are worth mentioning and looking forward to in future versions of Ruby:


There are a few bugs with this release that will require a 3.3.1 release soon. Keep an eye on the following: