Versions, Releases and Compatibility

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 yet: 4.1.0-preview1 appeared on 2026-10-12 and the final release is expected on 2026-12-25, so everything here about 4.1 is marked as preview and may change. Features newer than 3.0 name the version that introduced them. The page answers four questions: which Ruby versions exist and for how long they are fixed, what a version number promises, how a program or a project says which Ruby it needs, and how to find out what an upgrade will break. The previous page is Getting Started; the language-by-language list of changes continues on What’s New and Migrating Older Code.

Every output below was produced with Ruby 4.0.7 on arm64-darwin23 (macOS on Apple silicon), installed with mise. Values that depend on the machine, such as RUBY_PLATFORM, will differ on yours.

The release cycle

Ruby has one fixed release date: a new minor (or major) version is published every 25 December, and the Ruby core team publishes patch releases for the supported branches during the year. The pattern is visible in the release list on ruby-lang.org:

Branch First release Status on 2026-10-18

3.0

2020-12-25

end of life (2024-04-23)

3.1

2021-12-25

end of life (2025-03-26)

3.2

2022-12-25

end of life (2026-04-01)

3.3

2023-12-25

security maintenance (latest patch 3.3.12, 2026-07-16)

3.4

2024-12-25

normal maintenance (latest patch 3.4.11, 2026-09-23)

4.0

2025-12-25

normal maintenance (latest patch 4.0.7, 2026-09-15)

4.1

expected 2026-12-25

preview (4.1.0-preview1, 2026-10-12)

A release goes through previews (4.1.0-preview1, -preview2, …​) and a release candidate (-rc1) before the .0 release. Those names sort before the final version, which matters when a program compares versions (see Comparing versions). The 4.0 branch is a good picture of the patch cadence: 4.0.0 on 2025-12-25, then 4.0.1 (2026-01-13), 4.0.2 (2026-03-16), 4.0.3 (2026-04-21), 4.0.4 (2026-05-11), 4.0.5 (2026-05-20), 4.0.6 (2026-07-14) and 4.0.7 (2026-09-15): seven patch releases in nine months. Patch releases carry bug fixes and security fixes only; new features arrive with the next December release.

What a version number means

A Ruby version is MAJOR.MINOR.TEENY, for example 4.0.7: major 4, minor 0, teeny (the patch release) 7. Three points about it are easy to get wrong.

  • Minor releases may remove things. Ruby does not promise that code keeps working across minor versions the way a strict semantic-versioning scheme would. Each release’s NEWS file has a "Compatibility issues" section, and the removals are usually things that had been deprecated for several releases: Fixnum, Bignum and File.exists? went in 3.2, Kernel#open with a leading | in 4.0. See What each release changed.

  • The patch number is RUBY_VERSION, not RUBY_PATCHLEVEL. Since Ruby 2.1 the patchlevel is fully determined by the major, minor and teeny numbers, so the separate RUBY_PATCHLEVEL constant carries no extra information: it is 0 on 4.0.7, and the Ruby module documents -1 for a development build.

  • The library directory is versioned by ABI, not by patch. Gems and the standard library of every 4.0.x live under a directory named 4.0.0, which is what RbConfig::CONFIG["ruby_version"] returns. A gem with a C extension built for 4.0.7 therefore works on 4.0.3, and a 4.1 build needs it rebuilt. See C Extensions, FFI and WebAssembly.

What "4.0" means

Ruby 4.0 is the release after 3.4. The 3.5 name was used for the development series: the releases list shows 3.5.0-preview1 (2025-04-18), then 4.0.0-preview2, so the series shipped as 4.0. The number change did not come with a flag-day break. The 4.0.0 announcement lists two headline features, both labelled experimental, plus a set of smaller improvements:

  • Ruby::Box (experimental, off unless RUBY_BOX=1 is set), which isolates definitions such as monkey patches and global variables in separate boxes. It is described on Code Loading: require and autoload; this page only notes that Ruby::Box exists.

  • ZJIT (experimental), a new method-based JIT compiler built with Rust 1.85 or newer and enabled with --zjit. In 4.0 it is faster than the interpreter but not as fast as YJIT, and the announcement advises against production use. See YJIT, ZJIT and the VM.

  • Ractor::Port and the other Ractor API changes, Set and Pathname as core classes, the Ruby module, *nil no longer calling nil.to_a, and a leading && or || on a new line continuing the previous line. See Ractors, Sets and Program Structure and Lexical Elements.

The timeline shows the headline changes of each release since 3.0. The 4.1 entry comes from the preview announcement and can still change before the final release.

timeline title Ruby releases 3.0 to 4.1 and the headline changes of each 2020-12 : Ruby 3.0 : keyword arguments separated from positional : Ractor and the Fiber scheduler : RBS and TypeProf : pattern matching stable 2021-12 : Ruby 3.1 : YJIT experimental : debug gem and error_highlight : hash and keyword value omission : anonymous block forwarding 2022-12 : Ruby 3.2 : Data : Set autoloaded : anonymous rest and keyword rest forwarding : WASI : YJIT production ready : Regexp timeout 2023-12 : Ruby 3.3 : Prism bundled : MJIT removed and RJIT added : YJIT can be enabled at run time : performance warning category 2024-12 : Ruby 3.4 : it block parameter : Prism is the default parser : chilled strings : new error message and Hash inspect format 2025-12 : Ruby 4.0 : Ruby Box and ZJIT experimental : Ractor Port : Set and Pathname in core : RubyGems and Bundler 4 2026-12 : Ruby 4.1 : expected - preview only : Ractor local GC : autoload_relative : YJIT on Windows

Branch maintenance phases

Every release branch goes through the same three phases, and ruby-lang.org keeps the current state of each branch on one page:

  • Normal maintenance (bug fix): the branch receives general bug fixes and security fixes.

  • Security maintenance (security fix): only security fixes are backported.

  • End of life (EOL): the ruby-core team no longer supports the branch and no further patch release is made.

The page publishes the dates of each phase. The dates below are the ones listed on 2026-10-18; 3.4 and 4.0 are in normal maintenance with their end still TBD (not announced), and the EOL date of 3.3 is marked expected:

Branch Release Normal maintenance until End of life

3.0

2020-12-25

2023-04-01

2024-04-23

3.1

2021-12-25

2024-04-01

2025-03-26

3.2

2022-12-25

2025-04-01

2026-04-01

3.3

2023-12-25

2026-04-01

2027-03-31 (expected)

3.4

2024-12-25

TBD

TBD

4.0

2025-12-25

TBD

TBD

The maintenance windows of Ruby 3.0 to 4.1 on a timeline, from release through normal and security maintenance to end of life

The shape of the closed branches is regular: normal maintenance ends on 1 April, about two years and three months after the release, and end of life follows about a year later, around the end of March. That is an observation about 3.0 to 3.3, not a promise, and the page does not announce the dates for 3.4 and 4.0 yet. Practical consequences:

  • Run production on a branch in normal maintenance (today 4.0 or 3.4). In security maintenance (3.3) only security fixes are produced, and an EOL branch (3.2 and older) gets nothing, not even for a vulnerability. The Security page covers following the security announcements.

  • A library should declare the oldest Ruby it supports (see required_ruby_version) and test on every branch it claims to support.

  • A bug report should say which released version is affected and whether the problem also reproduces on the development branch (master). The reporting guide lists the three maintenance states in the same words as above.

The version constants and the Ruby module

A running program can ask the interpreter what it is. The constants have existed for a very long time; Ruby 4.0 additionally defines a top-level module named Ruby (the name was reserved in 3.4, where defining it warned under Warning[:deprecated]) that holds the same values without the RUBY_ prefix, because they describe the language implementation rather than the object system.

# frozen_string_literal: true

# Everything the running interpreter reports about itself.
puts "RUBY_VERSION        #{RUBY_VERSION}"
puts "RUBY_PATCHLEVEL     #{RUBY_PATCHLEVEL}"
puts "RUBY_RELEASE_DATE   #{RUBY_RELEASE_DATE}"
puts "RUBY_ENGINE         #{RUBY_ENGINE}"
puts "RUBY_ENGINE_VERSION #{RUBY_ENGINE_VERSION}"
puts "RUBY_PLATFORM       #{RUBY_PLATFORM}"
puts "RUBY_DESCRIPTION    #{RUBY_DESCRIPTION}"
puts "RUBY_COPYRIGHT      #{RUBY_COPYRIGHT}"

# Since 4.0 the same values live in the Ruby module, without the prefix.
puts Ruby::VERSION == RUBY_VERSION
puts Ruby::ENGINE == RUBY_ENGINE
puts Ruby.constants.sort.inspect

Output:

RUBY_VERSION        4.0.7
RUBY_PATCHLEVEL     0
RUBY_RELEASE_DATE   2026-09-15
RUBY_ENGINE         ruby
RUBY_ENGINE_VERSION 4.0.7
RUBY_PLATFORM       arm64-darwin23
RUBY_DESCRIPTION    ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin23]
RUBY_COPYRIGHT      ruby - Copyright (C) 1993-2026 Yukihiro Matsumoto
true
true
[:Box, :COPYRIGHT, :DESCRIPTION, :ENGINE, :ENGINE_VERSION, :PATCHLEVEL, :PLATFORM, :RELEASE_DATE, :REVISION, :VERSION]

Source: module Ruby

Ruby.constants also lists Box: that is the experimental Ruby::Box mentioned above, not a version constant.

The constants, with the Ruby:: name next to each:

Constant Ruby module name Meaning

RUBY_VERSION

Ruby::VERSION

the language version as a string, "4.0.7"; the same on every implementation that targets 4.0

RUBY_PATCHLEVEL

Ruby::PATCHLEVEL

0 on 4.0.7 and -1 on a development build; not a patch counter

RUBY_RELEASE_DATE

Ruby::RELEASE_DATE

the date of this release, "2026-09-15"

RUBY_ENGINE

Ruby::ENGINE

the implementation: "ruby" for the reference implementation (CRuby, also called MRI), other names for JRuby and TruffleRuby

RUBY_ENGINE_VERSION

Ruby::ENGINE_VERSION

the implementation’s own version; equal to RUBY_VERSION on CRuby, different on the others

RUBY_PLATFORM

Ruby::PLATFORM

the platform the interpreter was built for, "arm64-darwin23"

RUBY_REVISION

Ruby::REVISION

the commit the interpreter was built from

RUBY_DESCRIPTION

Ruby::DESCRIPTION

the full line printed by ruby -v

RUBY_COPYRIGHT

Ruby::COPYRIGHT

the copyright line printed by ruby --copyright

The same information is available without writing a program. ruby --version prints RUBY_DESCRIPTION, whose +PRISM suffix says that the Prism parser is compiled in (the default parser since 3.4). The short option -v also prints it, but it additionally switches on verbose mode ($VERBOSE becomes true) and then runs the script, so -v with a script is a different command from --version:

$ ruby --version
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin23]

$ ruby -e 'puts RUBY_VERSION'
4.0.7

$ ruby -v -e '1'
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin23]
-e:1: warning: possibly useless use of a literal in void context

Comparing versions

Version strings are not numbers and should not be compared as text. As strings, "3.10.0" sorts before "3.4.0" because the character 1 comes before 4; RubyGems ships a class that compares the dotted segments as integers. Gem::Version is available in every Ruby without a require (RubyGems is loaded at start-up), Gem.ruby_version returns the running version as one, and Gem::Requirement expresses a constraint such as the ones in a gemspec.

# frozen_string_literal: true

# Compare versions as versions, never as strings.
puts "3.10.0" >= "3.4.0"
puts Gem::Version.new("3.10.0") >= Gem::Version.new("3.4.0")

running = Gem.ruby_version
p running
p running.segments
p running.release
p running.prerelease?

preview = Gem::Version.new("4.1.0-preview1")
p preview
p preview.prerelease?
p preview < Gem::Version.new("4.1.0")
p preview > running

# A requirement is a set of comparison operators, as in a gemspec.
p Gem::Requirement.new("~> 4.0").satisfied_by?(running)
p Gem::Requirement.new(">= 3.3", "< 4.0").satisfied_by?(running)

# The library directory is versioned by ABI ("4.0.0"), not by patch release.
p RbConfig::CONFIG.values_at("MAJOR", "MINOR", "TEENY", "ruby_version")

Output:

false
true
#<Gem::Version "4.0.7">
[4, 0, 7]
#<Gem::Version "4.0.7">
false
#<Gem::Version "4.1.0.pre.preview1">
true
true
true
true
false
["4", "0", "7", "4.0.0"]

Points to notice in the output:

  • The string comparison answers false and the Gem::Version comparison true for the same two values.

  • Gem::Version.new("4.1.0-preview1") is normalised to 4.1.0.pre.preview1: any segment containing a letter makes the version a prerelease, which sorts before 4.1.0 but after 4.0.7. A requirement such as >= 4.1 is therefore not satisfied by that preview version string, although the preview is newer than every 4.0.x.

  • ~> 4.0 is the "pessimistic" operator: at least 4.0 and below 5. ">= 3.3", "< 4.0" passes two requirements that must both hold, so it rejects 4.0.7.

  • RbConfig::CONFIG holds the build-time facts. MAJOR, MINOR and TEENY are the three parts of the version and ruby_version is the ABI directory name discussed above.

The shelf gem uses this to fail early on a Ruby it was not tested on. The gemspec already refuses to install on an older Ruby (see below), so the guard matters for the cases that skip the gemspec: a checkout used directly, a vendored copy, or a script that loads the library with require_relative.

# frozen_string_literal: true

module Shelf
  class UnsupportedRubyError < StandardError; end

  # Fails early, with a readable message, on a Ruby the gem was not tested on.
  module RubySupport
    MINIMUM = Gem::Version.new("4.0")

    def self.check!(running = Gem.ruby_version)
      return running if running >= MINIMUM

      raise UnsupportedRubyError, "shelf needs Ruby #{MINIMUM} or newer, found #{running}"
    end
  end
end

p Shelf::RubySupport.check!

begin
  Shelf::RubySupport.check!(Gem::Version.new("3.3.12"))
rescue Shelf::UnsupportedRubyError => e
  puts "#{e.class}: #{e.message}"
end

Output:

#<Gem::Version "4.0.7">
Shelf::UnsupportedRubyError: shelf needs Ruby 4.0 or newer, found 3.3.12

The method takes the version as a default argument so that a test can pass a different one, as the last lines do, instead of trying to change RUBY_VERSION (a constant, and reassigning it prints a warning and breaks everything that reads it).

Source: Gem::Version and Comparable

Feature detection beats version checks

A version check says what the interpreter is called; a feature check says what it can do. Feature checks keep working on other implementations, on development builds and on a release that backports a method, and they read better: the test names the capability instead of the number it appeared in. Use respond_to? for a method on an object, method_defined? for an instance method of a class, and defined? for a constant or other construct.

# frozen_string_literal: true

# Ask the interpreter what it can do instead of what it is called.
checks = {
  "Set (autoloaded in 3.2, core in 4.0)" => defined?(Set) ? true : false,
  "Array#rfind (4.0)" => Array.method_defined?(:rfind),
  "Class#subclasses (3.1)" => Class.method_defined?(:subclasses),
  "Data (3.2)" => defined?(Data) ? true : false,
  "Ruby module (4.0)" => defined?(Ruby::VERSION) ? true : false,
  "Ractor::Port (4.0)" => defined?(Ractor::Port) ? true : false,
  "Ractor.yield (removed in 4.0)" => Ractor.respond_to?(:yield),
  "Pathname (core in 4.0)" => defined?(Pathname) ? true : false
}

checks.each { |name, present| puts "#{present.to_s.ljust(5)} #{name}" }

Output:

true  Set (autoloaded in 3.2, core in 4.0)
true  Array#rfind (4.0)
true  Class#subclasses (3.1)
true  Data (3.2)
true  Ruby module (4.0)
true  Ractor::Port (4.0)
false Ractor.yield (removed in 4.0)
true  Pathname (core in 4.0)

Three things the table shows. Set and Pathname are present without a require in 4.0 because they are core classes now; on 3.2 and later Set was autoloaded and on older versions it needed require "set", so a library that still runs on 3.x keeps the require (it is harmless). Ractor.yield is gone in 4.0, so respond_to? is the right probe for the old Ractor API. And Ractor::Port is a constant, so defined? is the right probe for the new one.

When a version check is the only option, such as a behaviour change with no new method, compare with Gem::Version, never with strings, and compare RUBY_VERSION (the language) rather than RUBY_ENGINE_VERSION, because an alternative implementation reports the language version it implements in RUBY_VERSION and its own release number in RUBY_ENGINE_VERSION.

Declaring the Ruby version of a project

A project records the Ruby it needs in up to three places, and each has a different reader.

File or setting Read by Purpose

.ruby-version

version managers and Bundler

one line, 4.0.7: the exact Ruby to use in this directory

the ruby line of the Gemfile

Bundler

refuses to run (and to resolve) on a Ruby that does not match

required_ruby_version in the gemspec

RubyGems and Bundler

a published gem’s minimum (and maximum) supported Ruby

.ruby-version and the version managers

.ruby-version is a plain text file with a version number on one line, a convention that version managers such as rbenv, chruby and RVM read, and that Bundler can read through the Gemfile (below). Version managers are community tools that ruby-lang.org only lists; the installation options are on Getting Started. One difference is worth knowing because it surprises people: mise reads .ruby-version only when idiomatic version files are enabled for Ruby (mise settings add idiomatic_version_file_enable_tools ruby); the setting is off by default, so in a directory with .ruby-version set to 4.0.7 a plain mise current can still report the system Ruby. Its own file, mise.toml, always works.

The ruby line of the Gemfile

Bundler’s ruby method states the Ruby a project needs. It accepts a literal version, several requirements that must all hold, a version file, and an engine for projects that run on another implementation. The three Gemfiles below are alternatives: each is a complete file of its own.

# frozen_string_literal: true

source "https://rubygems.org"

# Read the version from the file the version manager already uses.
ruby file: ".ruby-version"

gemspec
# frozen_string_literal: true

source "https://rubygems.org"

# Several requirements are ANDed, exactly as in a gemspec.
ruby ">= 4.0", "< 5"
# frozen_string_literal: true

source "https://rubygems.org"

# Pin an alternative implementation: the language version, then the engine's own.
ruby "4.0.0", engine: "jruby", engine_version: "10.1.2.0"

The first form reads .ruby-version (and, in the Bundler documentation, .tool-versions) so that the Gemfile and the version manager can never disagree. The third pins JRuby: the first argument is the language version, engine_version the implementation’s own; the numbers shown are an illustration (it was not run here, as JRuby is not installed), and a real Gemfile uses the two values that the engine prints for RUBY_VERSION and RUBY_ENGINE_VERSION. On CRuby the engine version must equal the Ruby version, and a patch level in the ruby line has been meaningless since Ruby 2.1.

When the running Ruby does not match, Bundler stops before running anything. With .ruby-version changed to 3.3.12, a command run through bundle exec fails like this (the path prefix is shortened):

.../bundler/definition.rb:520:in 'Bundler::Definition#validate_ruby!': Your Ruby version is 4.0.7, but your Gemfile specified 3.3.12 (Bundler::RubyVersionMismatch)

With the file set to 4.0.7 the same command runs, and bundle install records the version in Gemfile.lock under a RUBY VERSION heading next to BUNDLED WITH 4.0.20, so the lock file also tells a reader which Ruby it was resolved with. Ruby 4.0 ships RubyGems and Bundler 4.0.x (the 4.0 series share one version number); the latest Bundler supports every Ruby that has not reached end of life, and Bundler 4.0 itself requires Ruby 3.2 or newer. See RubyGems and Bundler.

required_ruby_version in a gemspec

A gem declares the Rubies it supports with required_ruby_version, as the shelf gemspec does:

# frozen_string_literal: true

Gem::Specification.new do |spec|
  spec.name = "shelf"
  spec.version = "0.1.0"
  spec.authors = ["Example Author"]
  spec.summary = "A small library catalogue."
  spec.license = "MIT"
  spec.required_ruby_version = ">= 4.0"
  spec.metadata["rubygems_mfa_required"] = "true"
end

RubyGems checks the requirement at install time. Building a copy of this gem with ">= 5.0" and installing it on 4.0.7 fails with:

ERROR:  Error installing ./shelf-0.1.0.gem:
	shelf-0.1.0 requires Ruby version >= 5.0. The current ruby version is 4.0.7.

Choose the requirement for the oldest Ruby the test suite really runs on. A lower bound (>= 4.0) is the normal case. An upper bound ("< 5") is rarely wise: it blocks users on a future Ruby that would have worked, and the only way to lift it is a new release of your gem. ~> 4.0 is the same as ">= 4.0", "< 5". See Creating and Publishing Gems, and Rake.

Deprecation warnings and the warning categories

Ruby tells you about things that will change through warnings. Which ones you see is controlled by a level and by categories, and the defaults are deliberately quiet.

Warning levels: -W, -w and $VERBOSE

The -W option sets the global $-W (and the related $VERBOSE), which decides how talkative the interpreter is:

Option $VERBOSE Effect

-W0

nil ($-W is 0)

silent: no warnings at all

-W1, or no option

false ($-W is 1)

moderate: the warnings that are on by default

-W2, -W or -w

true ($-W is 2)

verbose: additional warnings, for example global variable '$foo' not initialized

-v

true ($-W is 2)

prints the version, then behaves like -w for the program that follows

The options guide and ruby -h describe -w as a synonym for -W1, but the interpreter sets $-W to 2 and $VERBOSE to true for it, as the table (checked by running each option on 4.0.7) shows; treat -w as -W2. A script can also change the variable while running ($VERBOSE = nil silences everything, which is almost never a good idea in a library).

Categories: deprecated, experimental, performance and strict_unused_block

Since Ruby 2.7.2 deprecation warnings are no longer shown by default, which is why code that is about to break often runs without a sound. Warnings carry a category, and each category has its own switch. Warning.categories (added in 3.4) lists the categories of the running Ruby:

Category Default Added What it covers

:deprecated

off

2.7

features that will change or go away: mutating a chilled string literal, ObjectSpace._id2ref, passing arguments to Enumerable#to_set

:experimental

on

2.7

features still marked experimental

:performance

off

3.3

performance hints, for example redefining methods the JIT specially optimises (3.4)

:strict_unused_block

off

3.4

passing a block to a method that never uses it

The switches are the same everywhere: on the command line with -W:category and -W:no-category, in the environment with RUBYOPT, and in code with Warning[category] and Warning[category] = flag. -w and -W2 also turn :deprecated on.

$ ruby -e 's = "shelf"; s << "!"; puts s'
shelf!

$ ruby -W:deprecated -e 's = "shelf"; s << "!"; puts s'
-e:1: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
shelf!

$ ruby -W:deprecated --debug-frozen-string-literal -e 's = "shelf"; s << "!"; puts s'
-e:1: warning: literal string will be frozen in the future
-e:1: info: the string was created here
shelf!

$ RUBYOPT=-W:deprecated ruby -e 's = "shelf"; s << "!"'
-e:1: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)

$ ruby --enable-frozen-string-literal -e 's = "shelf"; s << "!"'
-e:1:in '<main>': can't modify frozen String: "shelf" (FrozenError)

This is the chilled string migration step of 3.4. A string literal in a file without a frozen_string_literal magic comment is not frozen, but mutating it emits a deprecation warning when :deprecated is on; "a".frozen? still answers false. Frozen string literals are not the default in 4.0, and the proposal to make them the default (Feature 20205) is still open, waiting on a migration plan. To get frozen literals today, add frozen_string_literal: true to each file, or pass --enable-frozen-string-literal; --disable-frozen-string-literal switches the chilled warning off. The effect on strings is described on Strings and Literals. Mutating the result of Symbol#to_s produces the same kind of warning (also from 3.4): warning: string returned by :sym.to_s will be frozen in the future.

Other warnings that a deprecation run can show in 4.0:

$ ruby -W:deprecated -e 'p ObjectSpace._id2ref(1.object_id)'
-e:1: warning: ObjectSpace._id2ref is deprecated
1

$ ruby -W:deprecated -e 'p [1].to_set(Set)'
-e:1: warning: passing arguments to Enumerable#to_set is deprecated
Set[1]

$ ruby -W2 -e 'p $foo'
-e:1: warning: global variable '$foo' not initialized
nil

$ ruby -W:strict_unused_block -e 'def m; end; m { }'
-e:1: warning: the block passed to 'Object#m' defined at -e:1 may be ignored

A practical habit follows: run the test suite once per upgrade with RUBYOPT=-W:deprecated (or -w) and read the warnings before moving to the next Ruby, because a warning in version N is the error in version N+1 or N+2. -W:no-experimental silences the experimental category, for example when a project uses Ractors on purpose.

The Warning module and Kernel#warn

Warning[] and Warning[]= read and set a category; Warning.categories lists them. Kernel#warn writes a warning and takes two keywords: uplevel: prefixes the message with the file and line of a caller (the usual way to point at the code that called a deprecated method), and category: makes the message conditional on that category being enabled. An unknown category raises ArgumentError from Warning[].

# frozen_string_literal: true

# Send warnings to standard output so the listing below keeps its order.
$stderr = $stdout

# The defaults come from the command line (-W:category) and RUBYOPT.
Warning.categories.each do |category|
  puts "#{category.inspect.ljust(22)} #{Warning[category]}"
end

# A category can be switched on and off at run time.
Warning[:deprecated] = true
warn "ISBN-10 input is deprecated", category: :deprecated
Warning[:deprecated] = false
warn "this one is dropped", category: :deprecated

# Plain warn is never filtered by category.
warn "always printed"

# uplevel prefixes the caller's file and line.
def old_api
  warn "Shelf::Catalog#find_all is deprecated, use #select", uplevel: 1
end
old_api

begin
  warn "bogus", category: :bogus
rescue ArgumentError => e
  puts e.message
end

Output:

:deprecated            false
:experimental          true
:performance           false
:strict_unused_block   false
ISBN-10 input is deprecated
always printed
warning_categories.rb:24: warning: Shelf::Catalog#find_all is deprecated, use #select
unknown category: bogus

The first assignment turns :deprecated on, so the ISBN-10 message is printed; after switching it off the same call is silent, while a warn without a category is never filtered. uplevel: 1 reports the line of the caller of old_api (line 24), not the line of the warn inside it.

All warnings, from the interpreter and from Kernel#warn, end in the singleton method Warning.warn, which writes to $stderr. Customising it is the supported way to filter, collect or redirect warnings: extend Warning with a module that defines warn and call super for the default behaviour. Never redefine the instance method Warning#warn, which removes the way back to the default. The category: keyword is passed to it since Ruby 3.0.

# frozen_string_literal: true

# Collect warnings instead of printing them, keyed by category.
module WarningCollector
  Entry = Data.define(:category, :message)

  @entries = []

  class << self
    attr_reader :entries
  end

  def warn(message, category: nil, **)
    WarningCollector.entries << Entry.new(category, message)
  end
end

Warning.extend(WarningCollector)
Warning[:deprecated] = true
Warning[:experimental] = true

warn "ISBN-10 input is deprecated", category: :deprecated
warn "Shelf::Store::Memory#each is slow", category: :performance
warn "plain message"
Warning.warn("direct call\n")
# The interpreter itself reports through Warning.warn too.
Object.const_set(:LOAN_DAYS, 14)
Object.const_set(:LOAN_DAYS, 21)

WarningCollector.entries.each { |entry| p entry }

Output:

#<data WarningCollector::Entry category=:deprecated, message="ISBN-10 input is deprecated\n">
#<data WarningCollector::Entry category=nil, message="plain message\n">
#<data WarningCollector::Entry category=nil, message="direct call\n">
#<data WarningCollector::Entry category=nil, message="warning_filter.rb:28: warning: already initialized constant LOAN_DAYS\n">
#<data WarningCollector::Entry category=nil, message="warning_filter.rb:27: warning: previous definition of LOAN_DAYS was here\n">

The warn with category :performance is dropped before it reaches Warning.warn because that category is off, while the interpreter’s own message about reassigning a constant arrives as two ordinary messages (the second names the earlier definition). A test suite can use the same hook to fail on any warning that comes from the project’s own files.

What each release changed

The NEWS file of each release is the authoritative list; the table collects the changes that most often break code written for an older Ruby, with the version that introduced each. Items marked (warning) were visible under -W:deprecated in the release before.

Release Language and core changes that can break older code

3.0

keyword arguments are separated from positional arguments (code that warned in 2.7 now raises ArgumentError or behaves differently); $SAFE and $KCODE are ordinary variables; yield inside a class body is a SyntaxError; a class variable overtaken by an ancestor’s raises RuntimeError; SortedSet is removed; Proc with *a, **kw no longer autosplats; deprecation warnings are hidden by default

3.1

multiple assignment evaluates in the same left-to-right order as single assignment; Hash and keyword values may be omitted ({x:, y:}); the pin operator takes expressions; one-line pattern matching is no longer experimental

3.2

Fixnum, Bignum, Struct::Group, Struct::Passwd, Random::DEFAULT, Dir.exists?, File.exists?, the taint and trust methods and Kernel#=~ are removed; a method that delegates keyword arguments through *args needs ruby2_keywords; error messages no longer escape control characters

3.3

Kernel#lambda with a non-lambda, non-literal block raises ArgumentError (warning); NoMethodError says "an instance of Array" instead of printing the receiver; MJIT is removed; RubyGems and Bundler warn when a gem that will become a bundled gem is required without a Gemfile entry; opening a subprocess through Kernel#open("|cmd") and the IO read methods is deprecated

3.4

it refers to the block parameter; string literals are chilled (mutating one warns under :deprecated); error messages open the method name with a straight quote instead of a backtick and show Class#method (see the formats below); Hash#inspect uses the key: value form for symbol keys and spaces around => otherwise; Prism is the default parser; Symbol#to_s results warn when mutated; a[0, &b] = 1 and keyword arguments in index assignment are errors; the ::Ruby name is reserved

4.0

*nil no longer calls to_a; a leading && or || continues the previous line; Set and Pathname are core classes and Set#inspect prints Set[1, 2]; Ractor.yield, Ractor#take, Ractor#close_incoming and Ractor#close_outgoing are removed in favour of Ractor::Port; process creation by a leading | in Kernel#open and the IO class methods is removed; ObjectSpace._id2ref is deprecated; Process::Status#& and >> are removed; --rjit is removed; backtraces no longer show internal frames and name the receiver’s class for "wrong number of arguments"

Preview of 4.1 (from 4.1.0-preview1, subject to change before 2026-12-25): classes and modules can be modified only by the Ractor that created them (other Ractors raise Ractor::IsolationError); alias of a method found only through the fallback to Object, or defined in a prepended module, warns and will change in 4.3; passing arguments to Set#to_set, Range#to_set and Enumerable#to_set, deprecated in 4.0, is removed; Proc#ruby2_keywords is deprecated and will be removed in 4.4; and new methods such as Module#descendants, Range#clamp and Integer#bit_count, autoload_relative and Ractor-local garbage collection are added.

# frozen_string_literal: true

# What code written for older Rubies meets on 4.0.
def attempt(label)
  result = yield
  puts "#{label}: #{result.inspect}"
rescue NameError, ArgumentError, SystemCallError => e
  puts "#{label}: #{e.class}: #{e.message}"
end

attempt("Fixnum (removed 3.2)") { Object.const_get(:Fixnum) }
attempt("File.exists? (removed 3.2)") { File.respond_to?(:exists?) }
attempt("SortedSet (removed 3.0, stub gone 4.0)") { Object.const_get(:SortedSet) }
attempt("IO.read with a pipe (removed 4.0)") { IO.read("|echo hi") }
attempt("Ractor.yield (removed 4.0)") { Ractor.respond_to?(:yield) }
attempt("Ractor#take (removed 4.0)") { Ractor.method_defined?(:take) }
attempt("Set#inspect (changed 4.0)") { Set[1, 2].inspect }
attempt("Hash#inspect (changed 3.4)") { { name: "Ico", "id" => 1 }.inspect }
attempt("NoMethodError text (changed 3.4)") { nil.upcase }

Output:

Fixnum (removed 3.2): NameError: uninitialized constant Fixnum
File.exists? (removed 3.2): false
SortedSet (removed 3.0, stub gone 4.0): NameError: uninitialized constant SortedSet
IO.read with a pipe (removed 4.0): Errno::ENOENT: No such file or directory @ rb_sysopen - |echo hi
Ractor.yield (removed 4.0): false
Ractor#take (removed 4.0): false
Set#inspect (changed 4.0): "Set[1, 2]"
Hash#inspect (changed 3.4): "{name: \"Ico\", \"id\" => 1}"
NoMethodError text (changed 3.4): NoMethodError: undefined method 'upcase' for nil

The script is a compact check of the table: removed constants raise NameError (the message is uninitialized constant followed by the name), removed methods are simply absent, the pipe form of IO.read is now treated as a file name that does not exist, and the two inspection formats show the 3.4 and 4.0 changes. The old and new formats side by side (the old ones are from the 3.3 and 3.4 NEWS and appear in many tutorials; the new ones are the output above):

Ruby 3.2 and older:  undefined method `upcase' for nil:NilClass
Ruby 3.3:            undefined method `upcase' for nil
Ruby 3.4 and newer:  undefined method 'upcase' for nil

Ruby 3.3 and older:  {:name=>"Ico", "id"=>1}
Ruby 3.4 and newer:  {name: "Ico", "id" => 1}

Default gems, bundled gems and the standard library

Many libraries that older tutorials call "the standard library" are no longer loaded from it. There are three kinds:

  • Default gems ship inside Ruby and load with a plain require, even under Bundler (for example json, set before 4.0, optparse, fileutils).

  • Bundled gems ship with Ruby and are installed with it, but under Bundler they must be listed in the Gemfile (or the gemspec), exactly like any gem.

  • Removed libraries are no longer shipped at all.

Libraries move from the first kind to the second over time. The releases so far:

Release Became bundled gems or was removed

3.3

racc became a bundled gem (and 3.3 began warning that the 3.4 list was coming)

3.4

mutex_m, getoptlong, base64, bigdecimal, observer, abbrev, resolv-replace, rinda, drb, nkf, syslog and csv

4.0

ostruct, pstore, benchmark, logger, rdoc, win32ole, irb, reline, readline and fiddle; the CGI library was removed from the default gems and only the escaping methods of cgi/escape remain; set/sorted_set.rb was removed

On the command line the difference is invisible: ruby -e 'require "logger"' works because the bundled gem is installed. Under Bundler without a Gemfile entry, the same require fails, with a helpful warning first:

-e:1: warning: logger used to be loaded from the standard library, but is not part of the default gems since Ruby 4.0.0.
You can add logger to your Gemfile or gemspec to fix this error.
.../lib/ruby/4.0.0/bundled_gems.rb:60:in 'Kernel.require': cannot load such file -- logger (LoadError)

gem list shows the difference: json (default: 2.18.0) is a default gem, while logger (1.7.0) and benchmark (0.5.0) are ordinary entries. The fix is a line in the Gemfile or the gemspec’s add_dependency. See Standard Library Tour and RubyGems and Bundler.

How features are proposed and decided

Ruby is developed in the open. The pieces a user meets:

  • The issue tracker at bugs.ruby-lang.org (a Redmine instance) holds bugs, feature requests and miscellaneous tickets. A ticket number appears in the release NEWS as [Feature #18980] or [Bug #20433], so any change in this page can be traced to its discussion.

  • A bug report needs the Ruby version (the output of ruby -v), a small reproduction, and a check that the problem also happens on the development branch. A report with no feedback for a long time is eventually rejected.

  • A feature request is a ticket of the Feature type, written as a proposal: abstract, background, the proposal itself, use cases, discussion and see-also links. The documentation advises a solid use case, a look at earlier discussions, and thinking about compatibility; turning the idea into a gem first is a good way to show demand.

  • Decisions on language changes are taken in the ticket. The tracker shows the pattern: a ticket is assigned to a committer, Yukihiro Matsumoto (Matz), the language’s designer, replies in it, and the status changes. Feature #20925 (a leading && or || continues the previous line) has Matz’s replies and was closed, and the change is listed in the 4.0 NEWS.

  • Backports to a supported branch are done by the branch’s backport manager, and only committers may request them. Each bug ticket carries a Backport field with one entry per branch (such as 3.3: UNKNOWN) that the manager updates.

flowchart TD idea["Idea or problem"] --> kind{"Bug or Feature?"} kind -->|Bug| bug["Bug ticket: ruby -v, small reproduction, reproduced on master"] kind -->|Feature| feat["Feature ticket: abstract, use cases, compatibility"] bug --> fix["Fix merged into master"] feat --> disc["Discussion in the ticket"] disc --> dec{"Matz decides"} dec -->|Accepted| impl["Implemented and merged into master"] dec -->|Rejected| closed["Ticket closed"] impl --> news["Listed in NEWS as Feature number"] fix --> bp{"Needed on a stable branch?"} bp -->|Yes| back["Backport manager picks it for a patch release"] bp -->|No| next["Waits for the next December release"] news --> next classDef stage fill:#e8f0f8,stroke:#1c4b73 classDef gate fill:#fff4e0,stroke:#b26a00 class idea,bug,feat,fix,disc,impl,news,back,next,closed stage class kind,dec,bp gate

A good example of how slow and careful the process can be is the block parameter it. A shorthand for a default block parameter was proposed more than once and was first closed when numbered parameters (_1) were merged; Feature #18980 reopened the question, it was implemented by a core committer, and it shipped in 3.4 (with a warning for the old meaning in 3.3). The question of making string literals frozen by default (Feature #20205) is still open in 2026: according to the ticket Matz still wishes to do it, and the missing piece is a migration plan, which is why 3.4 added chilled strings.

Other implementations

RUBY_ENGINE tells programs which implementation they run on. Each implementation tracks a language version (RUBY_VERSION) and has its own version number (RUBY_ENGINE_VERSION). The reference implementation, written in C and also called CRuby or MRI, is what the rest of this section documents. Behaviour that depends on the implementation, such as the Global VM Lock, is covered on Threads and the GVL.

  • JRuby (https://www.jruby.org/) runs Ruby on the Java virtual machine and integrates with Java libraries. JRuby 10.1.x, at 10.1.2.0 when this page was checked, targets Ruby 4.0 compatibility.

  • TruffleRuby (https://truffleruby.dev) is a high-performance implementation built on the Truffle language framework and GraalVM. Its release number follows the Ruby language version it targets: the newest announcement on its site when this page was checked is TruffleRuby 34, with full Ruby 3.4 compatibility, one language release behind CRuby’s 4.0.

  • mruby (https://mruby.org/) is a lightweight implementation, complying with part of the ISO Ruby standard, that is designed to be embedded in other applications. It is not a drop-in replacement for CRuby, and its version (4.0.0 at the time of writing) is its own number, not a Ruby language version.

A Gemfile that must run on more than one engine uses the ruby line with engine: (shown above), and a library that supports more than one tests on each. Do not infer features from RUBY_ENGINE alone: ask the question with a feature check.

Where older books and tutorials are outdated

Material written for Ruby 2.x and early 3.x is still useful for the language itself, but several statements about versions and the toolchain no longer hold on 4.0. The table lists the recurring ones.

Older statement Current state (version of the change)

String literals are frozen by default since 3.4

they are chilled: not frozen, but mutating one warns under -W:deprecated; the magic comment or --enable-frozen-string-literal is still needed in 4.0 (3.4)

error messages ending in nil:NilClass, with a backtick before the method name

the message names the receiver as nil or "an instance of ClassName" (3.3) and opens the method name with a straight quote (3.4); backtraces show Class#method (3.4)

Hash#inspect printed {:name=>"Ico"}

symbol keys print as name: "Ico" and other keys with spaces around => (3.4)

refinements arrived in Ruby 2.0

experimental in 2.0, no longer experimental in 2.1

pattern matching arrived in Ruby 2.7

experimental in 2.7; case/in stable in 3.0, one-line patterns stable in 3.1, find pattern stable in 3.2

Symbol#to_proc arrived in Ruby 1.9

Ruby 1.8.7

the interpreter parses and runs a file line by line

the whole file is parsed first, now by Prism (default in 3.4), then compiled to YARV bytecode

MJIT is the JIT

MJIT was removed in 3.3; YJIT is production ready since 3.2, ZJIT is experimental in 4.0, and --rjit was removed in 4.0

benchmark, logger, ostruct, observer, csv …​ are the standard library

bundled gems: they need a Gemfile entry under Bundler (3.4 and 4.0)

Set needs require "set" and SortedSet exists

Set is a core class (autoloaded in 3.2, core in 4.0); SortedSet is gone

DateTime for dates and times

Time; DateTime is deprecated (see Time and Dates)

Fixnum, Bignum, File.exists?, $SAFE, taint

removed (3.2, and $SAFE and the taint methods between 3.0 and 3.2)

Kernel#open("|command") runs a command

removed in 4.0: use IO.popen or Open3 (see Processes, Signals and Shell)

Ractor.yield and Ractor#take

removed in 4.0; use Ractor::Port (see Ractors)

_1 as the way to name a block parameter

it (3.4)

the macOS system Ruby

it is the old 2.6 series (end of life): install a current Ruby with a version manager

Common mistakes

  • Comparing RUBY_VERSION as a string: "3.10" < "3.4" is true. Use Gem::Version.

  • Testing for a version that is not out yet by comparing against its final number: previews and release candidates are prereleases (4.1.0.pre.preview1) and sort before 4.1.0, so a check on the exact release can reject a preview that already has the feature. Test the feature instead.

  • Pinning required_ruby_version to "~> 4.0.0" in a library: it blocks 4.1 although the code works there.

  • Treating a green run as proof of compatibility: deprecations are silent by default. Run the suite with -W:deprecated.

  • Silencing warnings with -W0 or $VERBOSE = nil in a library: it hides the very messages that announce the next breaking change, and it hides them from the application that loaded the library.

  • Assuming that a library from the "standard library" list is loadable under Bundler. Check whether it is a default gem or a bundled gem, and add bundled gems to the Gemfile.

  • Using the system Ruby of an operating system for development. It may be old, and upgrading it can break the system’s own tools; use a version manager and a .ruby-version file.

  • Relying on RUBY_PATCHLEVEL to tell patch releases apart.