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:

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

  2. Run the test suite with deprecations visible on the Ruby you are leaving: ruby -W:deprecated (or RUBYOPT=-W:deprecated). Deprecation warnings are off by default, so a green suite proves nothing about the next version. See Ruby Command Line and Environment.

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

  4. Update the tools first or together: rubocop with TargetRubyVersion, the Gemfile’s ruby line, the CI matrix and the .ruby-version file. RuboCop’s Lint cops (Lint/DeprecatedConstants, Lint/DeprecatedClassMethods, Lint/UnifiedInteger) flagged several of the removals below before they happened.

  5. Test under Bundler, not only with ruby script.rb: bundled gems that left the default gems fail only there (see below).

  6. 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 * and **

SyntaxError

ok

ok

ok

ok

Data.define

NameError

ok

ok

ok

ok

Struct.new(:a, :b).new(a: 1, b: 2) as keywords

a hash in the first member, with a warning

ok

ok

ok

ok

it as the block parameter

NameError

NameError

NameError, with a warning

ok

ok

Set without require

NameError

ok

ok

ok

ok

Pathname() without require

NoMethodError

NoMethodError

NoMethodError

NoMethodError

ok

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 of loan_label("Dune", options). The message to look for is wrong number of arguments (given 2, expected 1) or no implicit conversion of Hash.

  • Delegation through *args loses the keywords. A wrapper written def 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_keywords flags the collected hash so that *args passes it on as keywords again.

  • A Proc taking *args and **kwargs no 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}" }

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

Fixnum (the old names of Integer)

true, warns

false

false

false

false

File.exists? and Dir.exists?

true, warns

false

false

false

false

Object#taint and tainted?

true, warns

false

false

false

false

Random::DEFAULT

true, warns

false

false

false

false

Object#=~

true, warns

false

false

false

false

URI.escape

false

false

false

false

false

Ractor#take

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

Fixnum, Bignum

NameError: uninitialized constant Fixnum (removed 3.2; the 3.1 run shows the constant still resolving, with a deprecation warning)

Integer

File.exists?, Dir.exists?

NoMethodError (removed 3.2)

File.exist?, Dir.exist?

$SAFE, taint, untaint, trust

$SAFE became an ordinary global variable in 3.0 and no longer protects anything: assigning $SAFE = 1 is accepted and does nothing. The taint and trust methods were removed in 3.2

validate input explicitly; see Security

URI.escape, URI.unescape

NoMethodError (removed 3.0)

URI.encode_www_form_component, URI.encode_uri_component or CGI.escape, depending on whether the text is a query value or a path segment

Random::DEFAULT

NameError (removed 3.2)

Random.new, or the Random class methods such as Random.rand

Object#=~

NoMethodError (removed 3.2; it always returned nil and warned in 3.1)

call =~ on a String, Regexp or Symbol

SortedSet

removed from set in 3.0: a stub raised RuntimeError telling you to install the sorted_set gem (3.2, 3.3 and 3.4 runs); the stub itself is gone in 4.0 and the constant is simply undefined

sort a Set or an array when you need order, or add the sorted_set gem

ObjectSpace._id2ref

still works in 4.0 but warns ObjectSpace._id2ref is deprecated under -W:deprecated

keep your own registry, or ObjectSpace::WeakMap / WeakKeyMap

Ractor.yield, Ractor#take

removed in 4.0

Ractor::Port (below)

Process::Status#& and >>

removed in 4.0 (deprecated in 3.3)

Process::Status#exitstatus and #success?

--rjit

removed in 4.0: invalid option --rjit

run without it, or with --yjit

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

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
Version Result

3.1.7, 3.2.11

prints hi, no warning

3.3.12, 3.4.11

prints hi after a warning that calling Kernel#open with a leading pipe character "is deprecated and will be removed in Ruby 4.0; use IO.popen instead"

4.0.7

Errno::ENOENT: No such file or directory @ rb_sysopen - |echo hi: the string is now only ever a file name

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.

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 base64 "will no longer be part of the default gems starting from Ruby 3.4.0"

3.4.11

warnings for ostruct and logger ("will no longer be part of the default gems starting from Ruby 4.0.0"), then LoadError: cannot load such file — base64, because base64 already left in 3.4

4.0.7

warning: ostruct used to be loaded from the standard library, but is not part of the default gems since Ruby 4.0.0, then LoadError: cannot load such file — ostruct

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.

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.

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]

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 ruby

no warning

3.4.11 and 4.0.7, ruby -W:deprecated

warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)

4.0.7, --debug-frozen-string-literal -W:deprecated

the same warning plus info: the string was created here with the line of the literal

4.0.7, --enable-frozen-string-literal

FrozenError: can't modify frozen String: "Dune"

4.0.7, file with # frozen_string_literal: true

FrozenError: can't modify frozen String: "Dune"

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.

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

def total = price * qty (3.0)

def total; price * qty; end

endless method; a single expression

{ id: 1 } => { id: } (3.0)

id = h.fetch(:id)

rightward assignment and one-line pattern; NoMatchingPatternError on a mismatch

{ x:, y: } (3.1)

{ x: x, y: y }

value omission; the name must be a local variable or method

def run(&) = each(&) (3.1)

def run(&block) = each(&block)

anonymous block forwarding

def run(*, **) = call(*, **) (3.2)

def run(*args, **kw) = call(*args, **kw)

anonymous rest and keyword rest forwarding

list.map { it.upcase } (3.4)

list.map { _1.upcase } or list.map { |s| s.upcase }

it is the first block parameter; on 3.3 it warns and is a NameError (probe above)

list.map(&:upcase)

the same

works on every version, and is still the shortest form for one method call

a line starting with && or || (4.0)

&& at the end of the previous line

4.0 continues the previous expression; 3.4 reports unexpected '&&' and earlier Rubies a SyntaxError

*nil (4.0)

*nil.to_a or *[]

4.0 no longer calls nil.to_a, so splatting nil still gives nothing and no longer depends on NilClass#to_a

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

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
Version Hash#inspect nil.upcase error [1].upcase error

3.1.7, 3.2.11

{:name=>"Ico", "id"=>1}

undefined method `upcase' for nil:NilClass

undefined method `upcase' for [1]:Array

3.3.12

{:name=>"Ico", "id"=>1}

undefined method `upcase' for nil

undefined method `upcase' for an instance of Array

3.4.11, 4.0.7

{name: "Ico", "id" => 1}

undefined method 'upcase' for nil

undefined method 'upcase' for an instance of Array

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#inspect is Set[1, 2] in 4.0 and #<Set: {1, 2}> in 3.2 to 3.4.

  • Backtraces show Class#method instead 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.

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.

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

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)

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.

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.y parser. The ruby -v line of 4.0.7 shows +PRISM, and syntax errors have a new format (the syntax error found blocks above come from it). The Prism module 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; --rjit worked 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.11 defined?(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 --zjit or RubyVM::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.

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

class Money < Struct.new(:amount, :currency)

creates a throw-away anonymous superclass; mutable by default

Money = Data.define(:amount, :currency) (3.2), with a block for methods; keyword_init: true is unnecessary because keyword construction works for Struct since 3.2

class Cfg < Data.define(:a)

same extra anonymous class

Cfg = Data.define(:a) do ... end

OpenStruct.new(name: "Ico")

a bundled gem since 4.0, and it accepts any message name

Data.define, Struct, or a plain Hash

DateTime.now

the DateTime documentation says the class "is considered deprecated" and to use Time

Time.now, Time.utc; see Time and Dates

@instance ||= new as a singleton

not guarded by a lock, so two threads can both see nil and build two instances

require "singleton" and include Singleton, or a module with module-level state

require "set" at the top of every file

unnecessary on 3.2 and later, and on 4.0 Set is core

delete it when the minimum Ruby is 3.2 or newer

_1 as the block parameter

readable only for one-liners; cannot be named

it (3.4) for one parameter, a named parameter otherwise

rescue Exception

also catches SystemExit, Interrupt and NoMemoryError

rescue StandardError (or a more specific class)

raise NotImplementedError in an abstract method

NotImplementedError is a ScriptError, which a bare rescue does not catch

raise NoMethodError or a custom error; see Exceptions and Error Handling

obj.send(:name)

bypasses visibility

public_send unless private access is intended

method_missing alone

respond_to? lies about the object

also define respond_to_missing?

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.

Source: Data, Struct and Time

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 --enable-frozen-string-literal is still required in 4.0 (above)

3.4

Error messages

printed with a backtick and nil:NilClass: now undefined method 'x' for nil (above)

3.3, 3.4

Hash#inspect

{:name=>"Ico"} is now {name: "Ico"}

3.4

Bundled gems

benchmark and logger described as the standard library; observer not mentioned: they and ostruct, csv, base64 are bundled gems that need a Gemfile line under Bundler

3.4, 4.0

Struct and Data

subclassing Struct.new(...) or Data.define(...): assign the result to a constant instead; keyword_init: true is unnecessary

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; Symbol#to_proc "in 1.9": it is 1.8.7

2.0, 2.1, 1.8.7

Blocks

only _1: it is the readable single-parameter form

3.4

Modern syntax missing

endless methods (3.0), value omission (3.1), anonymous forwarding (3.1 and 3.2), one-line => and in, deconstruct and deconstruct_keys, the 4.0 leading && and || continuation

3.0 to 4.0

Set

absent: autoloaded in 3.2, core in 4.0, with Set[...] inspect; SortedSet removed

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 Fiber::Scheduler yields on I/O; M:N threads arrived in 3.3; the Ractor API is Ractor::Port in 4.0

3.0, 3.3, 4.0

Dates

DateTime in examples: use Time

3.x

Singletons

@instance ||= new: use the Singleton module

 — 

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: Fixnum, Bignum, File.exists?, taint and $SAFE, Kernel#open pipes, Ractor.yield and Ractor#take, the CGI library (only the escape methods remain)

3.0 to 4.0

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.max method that supposedly existed in 2.7 and was deprecated in 3.0 never existed. A safe_constantize call is ActiveSupport, not core Ruby, and returns nil instead 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. NotImplementedError is the wrong error for abstract methods. Several tool claims were also wrong: StandardRB is configurable through .standard.yml, bundler-audit reads Gemfile.lock and not Gemfile, and one arithmetic count of comparisons was off by 1000.

  • Code that does not run as printed. Undefined variables or constants, a missing end or def, unbalanced braces, a result that is computed and then discarded, an each-based finder that returns the array instead of the match, a sort on a symbol that is not a block, an attribute reader followed by a setter call, "spec" / "files" on a String, a case/in without else that raises NoMatchingPatternError, variables bound inside an alternative pattern (a SyntaxError), an inherited hook that never calls super, and a rescue of an exception class that real network errors do not inherit from.

  • Practices not to copy. rescue Exception; NotImplementedError as an abstract-method marker; subclassing the result of Struct.new or Data.define; OpenStruct; DateTime; an unguarded @instance ||= new singleton; send where public_send fits; method_missing without respond_to_missing?; hooks that do not call super; 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::IsolationError otherwise);

  • alias of a method found only through the fallback to Object, or defined in a prepended module, warns and is planned to change in 4.3;

  • arguments to Set#to_set, Range#to_set and Enumerable#to_set, deprecated in 4.0, are removed;

  • Proc#ruby2_keywords is 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.

A migration checklist

  1. Pin the target in .ruby-version, the Gemfile ruby line, the gemspec required_ruby_version and RuboCop’s TargetRubyVersion.

  2. Run the tests with -W:deprecated; add --debug-frozen-string-literal when a chilled-string warning appears.

  3. Search for Fixnum, Bignum, exists?, taint, $SAFE, URI.escape, Random::DEFAULT, Ractor.yield, .take, open("|, --rjit and --mjit.

  4. Add ** at call sites that pass an options hash to keyword parameters, and replace *args delegators.

  5. Run bundle exec once with an empty cache of loaded features (CI does this) and add the bundled gems the load errors name.

  6. Fix YAML.load calls that read aliases or arbitrary classes.

  7. Replace assertions on inspect and error text with assertions on values, classes and name / receiver.

  8. 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:deprecated is set.

  • Running scripts with plain ruby and shipping with Bundler: bundled-gem failures show only under bundle exec.

  • Assuming "frozen by default since 3.4": literals are chilled, and frozen? is still false.

  • Adding ruby2_keywords to new code. It exists for code that must also run on 2.7 and is being deprecated.

  • Silencing the warnings with -W0 or $VERBOSE = nil during an upgrade, which hides the messages that tell you what to fix.

  • Converting every _1 to it in blocks that use two parameters; it is only for the single-parameter case.

  • Copying a Ruby 2.x listing that uses OpenStruct, DateTime or Fixnum into a 4.0 code base because it "ran in the book".

  • Testing RUBY_VERSION strings instead of the feature (see the versions page).

  • Treating experimental features (Ractor, Ruby::Box, ZJIT) as stable because 4.0 ships them.