What’s New and Migrating Older Code
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, released 2026-09-15) and was checked on 2026-10-18. Ruby 4.1 is not released: only
4.1.0-preview1 exists and the final release is expected on 2026-12-25, so everything about 4.1 is marked as preview. It is
the practical companion of Versions, Releases and Compatibility,
which explains the release cycle and lists the NEWS items by release. This page goes the other way round: it starts from the
code and the habits you may have inherited (a Ruby 2.x or early 3.x code base, an older tutorial, a book written before 3.4) and
says what still works, what warns, what fails and what to write instead.
Every claim about when something changed was checked by running the same snippet on Ruby 3.1.7, 3.2.11, 3.3.12, 3.4.11 and
4.0.7 (all installed with mise on arm64-darwin23), or taken from the NEWS file of the release and linked after the example.
Ruby 2.7 and older were not installed, so statements about them come from the NEWS files and from the Ruby 3.0 announcement and
are labelled as such. The two books used to prepare this reference are cited, not quoted, on the
bibliography; this page only says where their statements are no
longer true.
A plan for an upgrade
An upgrade between minor versions is mostly a matter of finding the breakage before production does. The order that works:
-
Read the NEWS "Compatibility issues" section of every release you cross, not only the last one. They are linked from References. The table on the versions page lists the items that break code most often.
-
Run the test suite with deprecations visible on the Ruby you are leaving:
ruby -W:deprecated(orRUBYOPT=-W:deprecated). Deprecation warnings are off by default, so a green suite proves nothing about the next version. See Ruby Command Line and Environment. -
Upgrade one minor version at a time (3.1 to 3.2 to 3.3 and so on) when the code base is large. Each step has a short list of changes and the failures stay attributable.
-
Update the tools first or together:
rubocopwithTargetRubyVersion, the Gemfile’srubyline, the CI matrix and the.ruby-versionfile. RuboCop’s Lint cops (Lint/DeprecatedConstants,Lint/DeprecatedClassMethods,Lint/UnifiedInteger) flagged several of the removals below before they happened. -
Test under Bundler, not only with
ruby script.rb: bundled gems that left the default gems fail only there (see below). -
Treat newer syntax as a one-way door: once a file uses
it, anonymous argument forwarding or the 4.0 line continuation, it no longer parses on the older versions your CI may still run.
The probe below asks the running interpreter which of the newer features it understands. Each snippet is compiled with eval
inside a rescue, so a SyntaxError on an older Ruby becomes a line of output instead of a crash. It is a diagnostic for a
migration, not something to ship (see Security for eval).
# frozen_string_literal: true
# Which newer features does the running Ruby understand?
PROBES = {
"endless method (3.0)" => "def sq(n) = n * n; sq(3)",
"rightward pattern (3.0)" => "{ id: 1 } => { id: }; id",
"value omission (3.1)" => "x = 1; { x: }",
"anonymous & (3.1)" => "def a(&) = [1].map(&); a { _1 }",
"anonymous * and ** (3.2)" => "def b(*, **) = c(*, **); def c(*a, **k) = [a, k]; b(1, z: 2)",
"Data.define (3.2)" => "Data.define(:a).new(a: 1)",
"Struct keywords (3.2)" => "Struct.new(:a, :b).new(a: 1, b: 2).to_a",
"block parameter it (3.4)" => "[1].map { it + 1 }",
"Set without require (3.2)" => "Set[1].size",
"Pathname without require (4.0)" => "Pathname('a').to_s"
}.freeze
PROBES.each do |label, code|
result = begin
eval(code, TOPLEVEL_BINDING.dup, "probe", 1).inspect # rubocop:disable Security/Eval
rescue SyntaxError
"SyntaxError"
rescue NameError, ArgumentError => e
e.class.to_s
end
puts "#{label}: #{result}"
end
Output on 4.0.7 (every line is a success):
endless method (3.0): 9
rightward pattern (3.0): 1
value omission (3.1): {x: 1}
anonymous & (3.1): [1]
anonymous * and ** (3.2): [[1], {z: 2}]
Data.define (3.2): #<data a=1>
Struct keywords (3.2): [1, 2]
block parameter it (3.4): [2]
Set without require (3.2): 1
Pathname without require (4.0): "a"
The same probe on the other versions, condensed (ok means the same value as on 4.0.7 apart from the inspect format):
| Feature | 3.1.7 | 3.2.11 | 3.3.12 | 3.4.11 | 4.0.7 |
|---|---|---|---|---|---|
endless method, rightward pattern |
ok |
ok |
ok |
ok |
ok |
hash value omission, anonymous |
ok |
ok |
ok |
ok |
ok |
anonymous |
SyntaxError |
ok |
ok |
ok |
ok |
|
NameError |
ok |
ok |
ok |
ok |
|
a hash in the first member, with a warning |
ok |
ok |
ok |
ok |
|
NameError |
NameError |
NameError, with a warning |
ok |
ok |
|
NameError |
ok |
ok |
ok |
ok |
|
NoMethodError |
NoMethodError |
NoMethodError |
NoMethodError |
ok |
Source: NEWS for Ruby 3.2.0, NEWS for Ruby 3.4.0 and NEWS for Ruby 4.0.0
Keyword arguments are separate from positional ones (3.0)
The largest language change since 2.0 is the separation of positional and keyword arguments. Ruby 2.7 printed a warning whenever
the old behaviour was used (this comes from the 2.7 and 3.0 announcements; 2.7 was not installed here), and Ruby 3.0 removed
the old behaviour. The rule that remains is simple: a method call passes keywords only when it uses the keyword syntax
(key: value or **hash), and a method receives keywords only through keyword parameters or **.
# frozen_string_literal: true
# A Ruby 2.x style call: an options hash passed where keywords are declared.
def loan_label(title, days: 14)
"#{title} for #{days} days"
end
options = { days: 7 }
puts loan_label("Dune", **options)
begin
puts loan_label("Dune", options)
rescue ArgumentError => e
puts "#{e.class}: #{e.message}"
end
# The reverse: keywords collected into a trailing positional hash.
def collect(title, opts = {})
"#{title} #{opts.inspect}"
end
puts collect("Dune", days: 7)
puts collect("Dune", **options)
Output (identical on 3.1.7, 3.2.11, 3.3.12, 3.4.11 and 4.0.7; on 3.1 to 3.3 the last two lines print {:days=>7} because of the
older inspect format):
Dune for 7 days
ArgumentError: wrong number of arguments (given 2, expected 1)
Dune {days: 7}
Dune {days: 7}
The failing call is the one Ruby 2.x silently converted: a hash as the last positional argument became keywords. The two
conversions that remain are the ones in the last lines: keywords passed to a method that has no keyword parameters become a
positional hash, which is why collect still works. Practical consequences when migrating:
-
Add
**at the call site.loan_label("Dune", **options)instead ofloan_label("Dune", options). The message to look for iswrong number of arguments (given 2, expected 1)orno implicit conversion of Hash. -
Delegation through
*argsloses the keywords. A wrapper writtendef find(*args) = @target.find(*args)worked on 2.x because the final hash turned back into keywords. On 3.0 and later it does not (the first column of the table below). The fixes are(*args, **kwargs, &block), the(...)argument forwarding of 2.7 and 3.0, or the anonymous*,**and&forms of 3.1 and 3.2. For code that has to run on 2.7 and 3.x at once,ruby2_keywordsflags the collected hash so that*argspasses it on as keywords again. -
A
Proctaking*argsand**kwargsno longer autosplats a single array argument (listed in the 3.0 NEWS); lambdas and methods already followed the strict rule.
# frozen_string_literal: true
# One target, three ways of delegating to it.
search = ->(term, limit: 10) { "#{term}/#{limit}" }
class Proxy
def initialize(target)
@target = target
@sizes = []
end
# Ruby 2.x style: every argument is collected into one array.
def find_old(*args)
@sizes << args.size
@target.call(*args)
end
# The stop-gap for code that must also run on 2.7: flag the collected hash.
ruby2_keywords def find_flagged(*args)
@sizes << args.size
@target.call(*args)
end
# Ruby 3.2 and later: anonymous forwarding.
def find_new(*, **)
@target.call(*, **)
end
end
proxy = Proxy.new(search)
%i[find_old find_flagged find_new].each do |name|
puts "#{name}: #{proxy.public_send(name, 'Dune', limit: 3)}"
rescue ArgumentError => e
puts "#{name}: ArgumentError: #{e.message}"
end
Output on 4.0.7 (the same text on 3.2.11 and 3.4.11; 3.1.7 stops with a SyntaxError on find_new because anonymous * and
** forwarding arrived in 3.2):
find_old: ArgumentError: wrong number of arguments (given 2, expected 1)
find_flagged: Dune/3
find_new: Dune/3
ruby2_keywords is a compatibility tool and is on its way out: the 4.1 preview deprecates Proc#ruby2_keywords and plans to
remove it in 4.4 (from the preview announcement, so it may change). New code uses the anonymous forms. The full rules for
parameters and forwarding are on Methods and Parameters and
Calling Methods and Argument Forwarding.
APIs that were removed
Removed APIs do not fail in one way. A removed constant raises NameError, a removed method raises NoMethodError, and a
removed global-variable feature may just become an ordinary variable and stop protecting anything. The script asks, without
calling the old APIs, whether each one is still present:
# frozen_string_literal: true
require "uri"
# Does the running Ruby still have each API that older code uses?
CHECKS = {
"Fixnum constant (removed 3.2)" => -> { Object.const_defined?(:Fixnum) },
"File.exists? (removed 3.2)" => -> { File.respond_to?(:exists?) },
"Dir.exists? (removed 3.2)" => -> { Dir.respond_to?(:exists?) },
"Object#taint (removed 3.2)" => -> { Object.new.respond_to?(:taint) },
"Random::DEFAULT (removed 3.2)" => -> { Random.const_defined?(:DEFAULT) },
"Object#=~ (removed 3.2)" => -> { Object.new.respond_to?(:=~) },
"URI.escape (removed 3.0)" => -> { URI.respond_to?(:escape) },
"Ractor#take (removed 4.0)" => -> { Ractor.method_defined?(:take) }
}.freeze
CHECKS.each { |label, check| puts "#{label}: #{check.call}" }
Source: NEWS for Ruby 3.0.0, NEWS for Ruby 3.2.0 and NEWS for Ruby 4.0.0
Results of the same script on each version (true means the old API is still there):
| API | 3.1.7 | 3.2.11 | 3.3.12 | 3.4.11 | 4.0.7 |
|---|---|---|---|---|---|
|
true, warns |
false |
false |
false |
false |
|
true, warns |
false |
false |
false |
false |
|
true, warns |
false |
false |
false |
false |
|
true, warns |
false |
false |
false |
false |
|
true, warns |
false |
false |
false |
false |
|
false |
false |
false |
false |
false |
|
true |
true |
true |
true |
false |
The run on 4.0.7 prints:
Fixnum constant (removed 3.2): false
File.exists? (removed 3.2): false
Dir.exists? (removed 3.2): false
Object#taint (removed 3.2): false
Random::DEFAULT (removed 3.2): false
Object#=~ (removed 3.2): false
URI.escape (removed 3.0): false
Ractor#take (removed 4.0): false
What each removal means for old code, and what replaces it:
| Old code | What happens now | Write instead |
|---|---|---|
|
|
|
|
|
|
|
|
validate input explicitly; see Security |
|
|
|
|
|
|
|
|
call |
|
removed from |
sort a |
|
still works in 4.0 but warns |
keep your own registry, or |
|
removed in 4.0 |
|
|
removed in 4.0 (deprecated in 3.3) |
|
|
removed in 4.0: |
run without it, or with |
RuboCop flags direct uses of several of these names: Lint/UnifiedInteger for Fixnum, Lint/DeprecatedClassMethods for
File.exists? and Dir.exists?, Lint/DeprecatedConstants for Random::DEFAULT and Lint/UriEscapeUnescape for
URI.escape (all four fired on a draft of the script above that called the old APIs directly).
Source: NEWS for Ruby 3.0.0, NEWS for Ruby 3.2.0 and NEWS for Ruby 4.0.0
Kernel#open with a pipe (deprecated 3.3, removed 4.0)
Kernel#open("|command") and the matching forms of IO.read and IO.readlines ran a shell command when the name began
with a pipe character. It made any file-name argument a possible command injection, so it was deprecated with a warning in 3.3
and removed in 4.0. The behaviour per version, using a Kernel.open call on "|echo hi":
# frozen_string_literal: true
begin
puts Kernel.open("|echo hi", &:read)
rescue SystemCallError => e
puts "#{e.class}: #{e.message}"
end
Source: NEWS for Ruby 4.0.0 and IO.popen
| Version | Result |
|---|---|
3.1.7, 3.2.11 |
prints |
3.3.12, 3.4.11 |
prints |
4.0.7 |
|
The replacement names the command and its arguments separately, so nothing is parsed by a shell:
# frozen_string_literal: true
require "open3"
output, status = Open3.capture2("echo", "hi")
p [output, status.success?]
Output on 4.0.7:
["hi\n", true]
The process APIs (IO.popen, Open3, Process.spawn) are covered on
Processes, Signals and Shell.
Source: IO.popen, Open3 and NEWS for Ruby 4.0.0
Libraries that left the standard library
Older code and older books call logger, benchmark, ostruct, csv, base64 or observer "the standard library". They
are now bundled gems: installed together with Ruby, but, under Bundler, loadable only when the Gemfile or the gemspec lists
them. The releases that moved them (3.3 racc; 3.4 mutex_m, getoptlong, base64, bigdecimal, observer, abbrev,
resolv-replace, rinda, drb, nkf, syslog, csv; 4.0 ostruct, pstore, benchmark, logger, rdoc, win32ole,
irb, reline, readline, fiddle) are listed on the versions page; here is how that looks to a script.
The "older code you may meet" listing below is plain Ruby 2.x era code. It is shown because it is exactly what you will find in tutorials, and its behaviour was recorded:
require "ostruct"
require "logger"
require "base64"
# Older code you may meet: every line below ran fine on Ruby 2.x and 3.1.
Loan = Struct.new(:title, :days, keyword_init: true)
loan = Loan.new(title: "Dune", days: 14)
config = OpenStruct.new(log_level: :info)
log = Logger.new($stdout)
log.formatter = proc { |severity, _time, _prog, msg| "#{severity}: #{msg}\n" }
log.info("#{loan.title} #{config.log_level} #{Base64.strict_encode64(loan.title)}")
Run with plain ruby on 3.1.7, 3.3.12, 3.4.11 and 4.0.7 (with -W:deprecated) it prints the same line each time:
INFO: Dune info RHVuZQ==
Under bundle exec with a Gemfile that lists no gems, the outcome depends on the version:
| Version under Bundler | Result |
|---|---|
3.3.12 |
works; one warning that |
3.4.11 |
warnings for |
4.0.7 |
|
The fix is one line per gem in the Gemfile (or add_dependency in a gemspec). Better, remove the dependency when a core
alternative exists: Data or Struct instead of OpenStruct, and puts or warn for small scripts that do not need a
configurable logger. See
RubyGems and Bundler and
Standard Library Tour.
Source: NEWS for Ruby 4.0.0, NEWS for Ruby 3.4.0 and Bundler: Gemfile
Set and Pathname are core classes
Set was a library you had to require "set" until Ruby 3.2, which autoloaded it. Ruby 4.0 makes it a core class, so it
exists at boot, and Pathname was promoted the same way. Existing require "set" and require "pathname" lines still work and
are harmless; you may delete them. Set#inspect also changed in 4.0, which matters for tests that compare strings.
# frozen_string_literal: true
def probe(label)
puts "#{label}: #{yield.inspect}"
rescue StandardError => e
puts "#{label}: #{e.class}"
end
probe("Set without require") { Set[1, 2] }
probe("Pathname without require") { Pathname("lib").join("shelf") }
probe("SortedSet") { Object.const_get(:SortedSet) }
Output on 4.0.7:
Set without require: Set[1, 2]
Pathname without require: #<Pathname:lib/shelf>
SortedSet: NameError
On the other versions: 3.1.7 gives NameError for Set, then NoMethodError for Pathname() and NameError for SortedSet; 3.2.11,
3.3.12 and 3.4.11 give #<Set: {1, 2}> for the set, NoMethodError for Pathname() and RuntimeError for SortedSet (the
removal stub). The Set API is on Sets. A library that supports Ruby older than 3.2
keeps require "set" for that reason, not for 4.0.
Source: Set, Pathname and NEWS for Ruby 4.0.0
Chilled strings: not the same as frozen literals
Ruby 3.4 introduced chilled strings: a string literal in a file without a frozen_string_literal magic comment is not frozen,
but is marked so that mutating it reports a warning. Several older statements, in books included, say that string literals
"are frozen by default since 3.4"; that is not what shipped, and it is still not the case in 4.0 (BR 20205 about a default is
open). The facts, from the runs:
# No magic comment on purpose: these literals are chilled on Ruby 3.4 and later.
title = "Dune"
copy = +"Dune"
buffer = String.new("Dune")
p [title.frozen?, copy.frozen?, buffer.frozen?]
title << " Messiah"
copy << " Messiah"
p [title, copy, buffer]
Source: NEWS for Ruby 3.4.0
Output on 3.3.12, 3.4.11 and 4.0.7 (RuboCop’s Style/FrozenStringLiteralComment was excluded for this one listing because the
missing comment is the point):
[false, false, false]
["Dune Messiah", "Dune Messiah", "Dune"]
frozen? answers false for a chilled literal on every version. The difference is the warning, which appears only when
deprecation warnings are enabled:
| How it is run | Result for title << " Messiah" |
|---|---|
3.3.12, any flags |
no warning |
3.4.11 and 4.0.7, plain |
no warning |
3.4.11 and 4.0.7, |
|
4.0.7, |
the same warning plus |
4.0.7, |
|
4.0.7, file with |
|
Migrating means finding the literals that something mutates. Run the suite with -W:deprecated --debug-frozen-string-literal,
fix each reported place with +"text" (a mutable copy), String.new or dup, or better with a non-mutating style such as
interpolation or Array#join, and then add the magic comment to the file so the behaviour is the same on every Ruby you
support. Symbol#to_s results are chilled in the same way from 3.4. The strings page covers the details:
Strings.
Source: NEWS for Ruby 3.4.0, Ruby command-line options and Warning
Syntax you will meet in newer code
Code written for 3.4 and 4.0 reads differently from a 2.x tutorial. The first table is what the probe above measured; the second gives the older equivalent so that you can read, and when needed back-port, the new forms.
| Newer form (version) | Older equivalent | Notes |
|---|---|---|
|
|
endless method; a single expression |
|
|
rightward assignment and one-line pattern; |
|
|
value omission; the name must be a local variable or method |
|
|
anonymous block forwarding |
|
|
anonymous rest and keyword rest forwarding |
|
|
|
|
the same |
works on every version, and is still the shortest form for one method call |
a line starting with |
|
4.0 continues the previous expression; 3.4 reports |
|
|
4.0 no longer calls |
The two 4.0 changes in a runnable script (the continued line is the last statement of chatty):
# frozen_string_literal: true
# Two 4.0 syntax changes in one file.
def count(*args) = args.size
p [*nil]
p count(*nil)
verbose = ARGV.include?("-v")
quiet = ARGV.include?("-q")
chatty = !quiet
&& verbose
p chatty
Output on 4.0.7 with no arguments:
[]
0
false
On 3.4.11 the same file stops at the continued line with syntax error found (SyntaxError) and a pointer at && ("unexpected
'&&', ignoring it"); 3.1.7 reports unexpected &&, expecting end-of-input. The leading-operator style is the reason an older
parser cannot read 4.0 code, so a library that still supports 3.x keeps the operator at the end of the line. The block
parameter forms are compared on Blocks, Procs and Lambdas; the
numbered parameters _1 to _9 still work in 4.0, and it and _1 cannot be mixed in one block (4.0.7: numbered parameters are not allowed when 'it' is already used).
Source: NEWS for Ruby 4.0.0, NEWS for Ruby 3.4.0 and NEWS for Ruby 3.1.0
Inspect and error message formats
Two output formats changed, and both break tests that compare strings and tutorials that show output. Ruby 3.3 changed how an
error names its receiver, and 3.4 changed the quotes and the Hash#inspect format; 4.0 changed Set#inspect.
# frozen_string_literal: true
p({ name: "Ico", "id" => 1 })
begin
nil.upcase
rescue NoMethodError => e
puts e.message
end
begin
[1].upcase
rescue NoMethodError => e
puts e.message
end
Source: NEWS for Ruby 3.4.0 and NEWS for Ruby 3.3.0
| Version | Hash#inspect |
nil.upcase error |
[1].upcase error |
|---|---|---|---|
3.1.7, 3.2.11 |
|
|
|
3.3.12 |
|
|
|
3.4.11, 4.0.7 |
|
|
|
On 3.1.7 the message text also carried the error_highlight snippet (a caret line under the failing call); on 3.2 and later
that snippet is no longer part of message. Other formats that moved:
-
Set#inspectisSet[1, 2]in 4.0 and#<Set: {1, 2}>in 3.2 to 3.4. -
Backtraces show
Class#methodinstead of a bare method name (3.4), and 4.0 stopped showing internal frames of C-implemented methods and names the receiver’s class in "wrong number of arguments" backtraces.
The durable fix for code that parses messages is to not parse them. NameError#name and NoMethodError#receiver return the same
values on every version:
# frozen_string_literal: true
begin
nil.upcase
rescue NoMethodError => e
puts "name=#{e.name.inspect} receiver=#{e.receiver.inspect}"
end
Output (3.1.7 and 4.0.7 alike):
name=:upcase receiver=nil
For tests, compare data structures rather than their inspect text (assert_equal({ name: "Ico" }, result) works on every
version), or use assert_raises with the exception class and, when the text matters, a regular expression for the part that is
stable. The exception pages are Exceptions and Error Handling
and Hashes.
Source: NEWS for Ruby 3.4.0, NEWS for Ruby 3.3.0 and NameError
YAML.load is safe by default (Psych 4)
Ruby 3.1 bundled Psych 4.0 (3.1.7 reports 4.0.4; 3.4.11 reports 5.2.2 and 4.0.7 5.3.1). Since Psych 4, YAML.load is the
safe loader: it refuses arbitrary Ruby objects and, as a separate rule, YAML aliases. Configuration files that use anchors and
merge keys, which older tutorials load with plain YAML.load, now need aliases: true.
# frozen_string_literal: true
require "yaml"
def probe(label)
puts "#{label}: #{yield.inspect}"
rescue Psych::Exception => e
puts "#{label}: #{e.class}: #{e.message}"
end
defaults = <<~YAML
base: &base { days: 14 }
premium:
<<: *base
days: 28
YAML
probe("alias with load") { YAML.load(defaults) }
probe("alias with aliases: true") { YAML.load(defaults, aliases: true) }
probe("object tag with load") { YAML.load("--- !ruby/object:Object {}\n") }
probe("symbol with load") { YAML.load(":days: 14") }
Output on 4.0.7:
alias with load: Psych::AliasesNotEnabled: Alias parsing was not enabled. To enable it, pass `aliases: true` to `Psych::load` or `Psych::safe_load`.
alias with aliases: true: {"base" => {"days" => 14}, "premium" => {"days" => 28}}
object tag with load: Psych::DisallowedClass: Tried to load unspecified class: Object
symbol with load: {days: 14}
On 3.1.7 the same script gives Psych::BadAlias: Unknown alias: base for the first probe (newer Psych versions raise a more specific
exception, AliasesNotEnabled), the same DisallowedClass for the object tag, and {:days=>14} for the symbol: symbols are
permitted by YAML.load on both versions. Use YAML.safe_load(text, permitted_classes: [Symbol, Date], aliases: true) to name
exactly what a file may contain, and YAML.unsafe_load only for files you wrote yourself. See
Serialization: JSON, YAML, CSV and Marshal.
Source: Psych.load and NEWS for Ruby 3.1.0
The Ractor API changed in 4.0
Ractors are still experimental in 4.0, and their communication API was replaced. Ractor.yield and Ractor#take, the pair that
older Ractor examples use to return values, were removed together with Ractor#close_incoming and Ractor#close_outgoing.
Results now come from Ractor#value, and messages travel through a Ractor::Port.
# frozen_string_literal: true
r = Ractor.new { Ractor.yield 1; 2 }
p r.take
Source: NEWS for Ruby 4.0.0
This older form prints 1 on 3.1.7 to 3.4.11 (after the one-line warning that Ractor is experimental). On 4.0.7 it fails:
ractor.rb:3: warning: Ractor API is experimental and may change in future versions of Ruby.
ractor.rb:4:in '<main>': undefined method 'take' for an instance of Ractor (NoMethodError)
Source: Ractor and NEWS for Ruby 4.0.0
The current form:
# frozen_string_literal: true
r = Ractor.new { 1 + 1 }
p r.value
port = Ractor::Port.new
worker = Ractor.new(port) { |out| out << :ready }
p port.receive
worker.join
Output on 4.0.7 (after the same experimental warning):
2
:ready
Because the whole feature is labelled experimental, treat this API as the best current answer, not a stable contract: the 4.1 preview already tightens class and module modification by non-owner Ractors. The Ractor page has the model and the isolation rules: Ractors.
Source: Ractor, Ractor::Port and NEWS for Ruby 4.0.0
The parser and the JIT compilers
The way Ruby parses and runs a file changed twice, and older descriptions ("the interpreter parses and executes line by line") were never accurate for the whole file:
-
Parsing: Ruby parses the whole file into a syntax tree, then compiles it to YARV bytecode, then runs it. Since 3.4 the parser is Prism, which replaced the older
parse.yparser. Theruby -vline of 4.0.7 shows+PRISM, and syntax errors have a new format (thesyntax error foundblocks above come from it). ThePrismmodule exposes the parser to Ruby code. -
MJIT and RJIT: MJIT, the C-compiler-based JIT of 2.6, was removed in 3.3 and replaced by RJIT, a Ruby-written JIT meant for experiments;
--rjitworked on 3.3.12 and is an invalid option on 4.0.7, where RJIT is gone. -
YJIT: the production JIT. It was production ready in 3.2 and can be enabled while the program runs since 3.3 (
RubyVM::YJIT.enable); on 3.4.11defined?(RubyVM::YJIT)is"constant". -
ZJIT: new in 4.0 and experimental: a method-based compiler built with Rust 1.85 or newer and enabled with
--zjitorRubyVM::ZJIT.enable. NEWS says it is faster than the interpreter but not yet as fast as YJIT and advises against production use. It is described here only so that you recognise the name; the YJIT page has the details: YJIT, ZJIT and the VM.
For migration the consequence is mostly negative: scripts that passed --mjit or --rjit need the flag removed, and tools
that parsed Ruby through Ripper or RubyVM::AbstractSyntaxTree should be checked against Prism’s output.
Source: NEWS for Ruby 4.0.0, ZJIT and YJIT
Idioms that aged
Besides removals, some idioms still run but have a better replacement. Older books and tutorials teach the left column.
| Older idiom | Why it aged | Current form |
|---|---|---|
|
creates a throw-away anonymous superclass; mutable by default |
|
|
same extra anonymous class |
|
|
a bundled gem since 4.0, and it accepts any message name |
|
|
the |
|
|
not guarded by a lock, so two threads can both see |
|
|
unnecessary on 3.2 and later, and on 4.0 |
delete it when the minimum Ruby is 3.2 or newer |
|
readable only for one-liners; cannot be named |
|
|
also catches |
|
|
|
raise |
|
bypasses visibility |
|
|
|
also define |
A single script shows the modern replacements side by side. It uses only core classes plus open3 and yaml, so it needs no
Gemfile entry on 4.0:
# frozen_string_literal: true
require "open3"
require "yaml"
# The modern replacements for the commonly met older idioms.
Loan = Data.define(:title, :days) do
def due(from) = from + (days * 86_400)
end
loan = Loan.new(title: "Dune", days: 14)
p loan
p loan.due(Time.utc(2026, 10, 1))
tags = Set.new(%w[scifi classic])
tags << "scifi"
p tags
p File.exist?(".")
output, status = Open3.capture2("echo", "hi")
p [output, status.success?]
rules = YAML.load("base: &base { days: 14 }\nextra:\n <<: *base\n", aliases: true)
p rules
p [*nil]
Output on 4.0.7:
#<data Loan title="Dune", days=14>
2026-10-15 00:00:00 UTC
Set["scifi", "classic"]
true
["hi\n", true]
{"base" => {"days" => 14}, "extra" => {"days" => 14}}
[]
The value-object pages are Struct, Data and Value Objects and Classes and Objects.
Where older books and tutorials are outdated
The two books consulted for this reference are a book of Ruby design and clean-code advice and a book on plain-Ruby application architecture. Both are useful for ideas and both predate parts of 3.4 and 4.0. Statements that no longer hold, with the version where each changed (the corrections are paraphrased, not copied):
| Area | Outdated statement and current state | Changed in |
|---|---|---|
Frozen strings |
"literals are frozen by default since 3.4": they are chilled; the magic comment or |
3.4 |
Error messages |
printed with a backtick and |
3.3, 3.4 |
|
|
3.4 |
Bundled gems |
|
3.4, 4.0 |
|
subclassing |
3.2 |
Pattern matching |
"introduced in Ruby 2.7": experimental in 2.7, stable in 3.0; pin expressions in 3.1; find pattern stable in 3.2 |
2.7 to 3.2 |
Version history |
refinements "arrived in 2.0": experimental there, stable in 2.1; |
2.0, 2.1, 1.8.7 |
Blocks |
only |
3.4 |
Modern syntax missing |
endless methods (3.0), value omission (3.1), anonymous forwarding (3.1 and 3.2), one-line |
3.0 to 4.0 |
|
absent: autoloaded in 3.2, core in 4.0, with |
3.2, 4.0 |
Concurrency |
"more threads never help" and "fibers yield on I/O automatically since Ruby 3": threads do help I/O-bound work, and only a fiber running under a |
3.0, 3.3, 4.0 |
Dates |
|
3.x |
Singletons |
|
— |
Parser and JIT |
"parsed and executed line by line": whole-file parse by Prism (3.4), YARV bytecode, YJIT (production ready 3.2), ZJIT (4.0, experimental); MJIT and RJIT removed |
3.3, 3.4, 4.0 |
Removed APIs |
not covered but needed to read old code: |
3.0 to 4.0 |
Related pages for the full explanations: Threads and the GVL, Fibers and the Fiber Scheduler, Pattern Matching and Refinements and Monkey Patching.
Errors and bad practices in the books
Some statements in the books were wrong even for the Ruby they targeted, and some code listings do not run as printed. The list is by topic so that you can recognise the pattern; none of the code or wording is repeated here.
-
Factual errors. A
Math.maxmethod that supposedly existed in 2.7 and was deprecated in 3.0 never existed. Asafe_constantizecall is ActiveSupport, not core Ruby, and returnsnilinstead of raising. "DSL" means domain-specific language, not "dynamic scripting language". A predicate method (?) is not required to return a Boolean, only a truthy or falsy value, and a bang method (!) marks the more dangerous twin of another method, not necessarily one that mutates its receiver.NotImplementedErroris the wrong error for abstract methods. Several tool claims were also wrong: StandardRB is configurable through.standard.yml, bundler-audit readsGemfile.lockand notGemfile, and one arithmetic count of comparisons was off by 1000. -
Code that does not run as printed. Undefined variables or constants, a missing
endordef, unbalanced braces, a result that is computed and then discarded, aneach-based finder that returns the array instead of the match, asorton a symbol that is not a block, an attribute reader followed by a setter call,"spec" / "files"on aString, acase/inwithoutelsethat raisesNoMatchingPatternError, variables bound inside an alternative pattern (aSyntaxError), aninheritedhook that never callssuper, and arescueof an exception class that real network errors do not inherit from. -
Practices not to copy.
rescue Exception;NotImplementedErroras an abstract-method marker; subclassing the result ofStruct.neworData.define;OpenStruct;DateTime; an unguarded@instance ||= newsingleton;sendwherepublic_sendfits;method_missingwithoutrespond_to_missing?; hooks that do not callsuper; and reading error text from the old formats.
Book claims that concern Rails rather than Ruby are left out of this list, because Rails is out of scope for this section. One
example is DiLeo’s description of the Rails cache stores: the default store is not MemCacheStore, and RedisCacheStore has
existed since Rails 5.2.
What the 4.1 preview adds
Ruby 4.1 is not released. The items below come from the 4.1.0-preview1 announcement and the development NEWS, were not run
here, and can change before 2026-12-25; the versions page marks the same list. If you are preparing a migration, the ones that
touch existing code are:
-
classes and modules can be modified only by the Ractor that created them (
Ractor::IsolationErrorotherwise); -
aliasof a method found only through the fallback toObject, or defined in a prepended module, warns and is planned to change in 4.3; -
arguments to
Set#to_set,Range#to_setandEnumerable#to_set, deprecated in 4.0, are removed; -
Proc#ruby2_keywordsis deprecated, to be removed in 4.4.
New methods such as Module#descendants, Range#clamp and Integer#bit_count and the autoload_relative method are additions
only. Test against the preview in CI as an allowed failure and read the final NEWS on release day rather than the
preview text.
Source: Ruby 4.1.0-preview1 Released
A migration checklist
-
Pin the target in
.ruby-version, the Gemfilerubyline, the gemspecrequired_ruby_versionand RuboCop’sTargetRubyVersion. -
Run the tests with
-W:deprecated; add--debug-frozen-string-literalwhen a chilled-string warning appears. -
Search for
Fixnum,Bignum,exists?,taint,$SAFE,URI.escape,Random::DEFAULT,Ractor.yield,.take,open("|,--rjitand--mjit. -
Add
**at call sites that pass an options hash to keyword parameters, and replace*argsdelegators. -
Run
bundle execonce with an empty cache of loaded features (CI does this) and add the bundled gems the load errors name. -
Fix
YAML.loadcalls that read aliases or arbitrary classes. -
Replace assertions on
inspectand error text with assertions on values, classes andname/receiver. -
Only then adopt the new syntax (
it, endless methods, anonymous forwarding) file by file.
Common mistakes
-
Reading a green test run as proof that nothing is deprecated: deprecation warnings are hidden unless
-W:deprecatedis set. -
Running scripts with plain
rubyand shipping with Bundler: bundled-gem failures show only underbundle exec. -
Assuming "frozen by default since 3.4": literals are chilled, and
frozen?is stillfalse. -
Adding
ruby2_keywordsto new code. It exists for code that must also run on 2.7 and is being deprecated. -
Silencing the warnings with
-W0or$VERBOSE = nilduring an upgrade, which hides the messages that tell you what to fix. -
Converting every
_1toitin blocks that use two parameters;itis only for the single-parameter case. -
Copying a Ruby 2.x listing that uses
OpenStruct,DateTimeorFixnuminto a 4.0 code base because it "ran in the book". -
Testing
RUBY_VERSIONstrings instead of the feature (see the versions page). -
Treating experimental features (
Ractor,Ruby::Box, ZJIT) as stable because 4.0 ships them.
References
-
Ractor and Ractor::Port