Skip to content

What changed, 1.9 to 4.0

Level: 201 · anyone reading Ruby code or answers written for an older Ruby, and anyone who wants the Python timeline beside it

One line: Ruby's releases each added a few things you will meet in old code and new — keyword arguments (2.0), refinements (2.1), &. (2.3), pattern matching (2.7), keyword separation and Ractor (3.0), Data (3.2), it (3.4), a core Set (4.0) — and the program below asks this interpreter which of them it has, so the history is checked against the Ruby that ran it.

The table is prose (Not machine-checked here.); every row of the program is a measurement. A feature is checked either by compiling a snippet (RubyVM::InstructionSequence.compile raises SyntaxError for syntax the parser does not know) or by asking a class whether a method or constant exists.

Release Year What arrived
1.9 2007 YARV bytecode VM, strings carry an encoding, {a: 1} symbol-key syntax, Enumerator, Fiber
2.0 2013 keyword arguments, Module#prepend, refinements as experimental, %i[]
2.1 2013 refinements official, def returns a Symbol, Rational/Complex literals (3r, 2i)
2.3 2015 &. safe navigation, # frozen_string_literal: true, Hash#dig, Array#dig
2.4 2016 Fixnum and Bignum merged into Integer, Comparable#clamp, Regexp#match?
2.5 2017 rescue/else/ensure inside do…end blocks, yield_self, Hash#slice
2.6 2018 endless range (1..), then, Proc#>> and <<, Array#union, the first MJIT
2.7 2019 pattern matching (case/in) experimental, ... argument forwarding, numbered block parameters _1, beginless range
3.0 2020 keyword arguments separated from positional (a Hash no longer fills **opts), Ractor, endless def f = 1, RBS and TypeProf, Hash#except
3.1 2021 {x:} hash shorthand, Class#subclasses, YJIT (experimental), pin expressions ^(expr)
3.2 2022 Data.define, anonymous */**/& forwarding, Regexp.linear_time? and the regex match cache, Integer#ceildiv, WebAssembly support
3.3 2023 Prism parser shipped as a gem, RJIT replaces MJIT, Range#reverse_each, bigdecimal/csv/racc warned as leaving the default gems
3.4 2024 it block parameter, chilled string literals (a warning on mutation, not an error), error messages quote names as 'name' instead of `name', Prism as the default parser, **nil in hash patterns
4.0 2025 Set in core (C implementation), Ractor::Port replacing Ractor.yield/take, ZJIT (a second JIT), Ruby::Box namespaces (experimental), Warning.categories, old it-shadowing warnings gone

Verified output of ruby_versions_what_changed_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. now  RUBY_VERSION >= "4.0"                    true   (major 4)
 2. 2.0  keyword arguments, Module#prepend        def f(a:, b: 2) compiles: true   prepend: true
 3. 2.1  refinements                              Refinement class: true
 4. 2.3  &. safe navigation, Hash#dig             nil&.length compiles: true   dig: true
 5. 2.4  one Integer class                        Fixnum defined: false   1.class: Integer   (2**100).class: Integer
 6. 2.5  rescue inside do...end blocks            compiles: true
 7. 2.6  endless range, then, proc >>             (1..) compiles: true   then: true   >>: true
 8. 2.7  pattern matching, ... forwarding, _1     case/in: true   (...): true   _1: true
 9. 3.0  keyword separation, Ractor, endless def  f({a: 1}) no longer fills **opts: true   Ractor: true   def f = 1: true
10. 3.1  {x:} shorthand, Class#subclasses         compiles: true   subclasses: true
11. 3.2  Data, anonymous * ** &, linear_time?     Data.define: true   def f(*, **, &) = g(*, **, &): true   Regexp.linear_time?: true
12. 3.3  Prism parser ships with Ruby             require "prism" then Prism defined: true
13. 3.4  it, chilled literals, 'name' quoting     [1].map { it }: true   "a".frozen?: false (chilled, not frozen)   message: "undefined method 'foo' for nil"
14. 4.0  core Set, Ractor::Port, ZJIT, Ruby::Box  Set needs no require and is C code: true   Ractor::Port: true   RubyVM::ZJIT: true   Ruby::Box: true
15. 4.0  Warning.categories                       [:deprecated, :experimental, :performance, :strict_unused_block]

Reading the output

Row 1 fixes the interpreter: a 4.x. Rows 2–12 are the features old tutorials assume you do or do not have, each answered true by this Ruby: keyword arguments and prepend (2.0), the Refinement class (2.1), &. and dig (2.3), one Integer class — Fixnum is no longer defined and 2**100 is an Integer like 1 (2.4), rescue inside a do…end block (2.5), (1..), then and >> (2.6), case/in, ... and _1 (2.7). Row 9 is the 3.0 change that broke the most code: a hash passed positionally no longer fills **opts, so kw.call({a: 1}) raises ArgumentError — the row prints true because it did. Rows 10 and 11 are the 3.1 shorthand and 3.2's Data, anonymous forwarding and Regexp.linear_time?. Row 12 requires prism, the parser that became the default in 3.4. Row 13 is 3.4: it compiles, "a".frozen? is false because a chilled literal is not frozen — it only warns on mutation when deprecation warnings are on — and the NoMethodError message quotes the name as 'foo' in the new style. Row 14 is 4.0: Set#add has no Ruby source_location because Set is now C code in core, and the constants Ractor::Port, RubyVM::ZJIT and Ruby::Box exist. Row 15 prints the warning categories 4.0 knows, :strict_unused_block being the newest.

Compared with Python

Python's timeline runs on a yearly cadence since 3.9 and the twin checks it the same way, with compile() for syntax and hasattr for library additions: the walrus operator and positional-only parameters (3.8), dict | dict and str.removeprefix (3.9), match statements, zip(strict=True) and itertools.pairwise (3.10), exception groups with except*, tomllib and typing.Self (3.11), and 3.12's nested quotes inside f-strings, the type statement, def f[T] generic syntax, itertools.batched and sys.monitoring. Rows 8 and 9 are where this library's two CI machines part ways: copy.replace, the free-threaded build and the new REPL arrived in 3.13, and template strings (t"…") and deferred evaluation of annotations in 3.14, so those rows name the check but do not print its answer — the key must be identical under 3.12 on Ubuntu and 3.14 on macOS, and hasattr(copy, "replace") is False on one and True on the other. That is row 10's lesson: a feature probe is worth exactly the set of interpreters it ran on. Two contrasts with Ruby: Python's 2-to-3 break happened once, in 2008, and Ruby has never made a comparable one — 3.0's keyword separation is the largest — and Python removes things on a schedule (distutils in 3.12, asynchat/imp in 3.12 and 3.13's "dead batteries") where Ruby moves libraries out of the default set into bundled gems instead (Not machine-checked here.).

Verified output of ruby_versions_what_changed_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. now  sys.version_info >= (3, 12)              True   (major 3)
 2. 3.8  walrus :=, positional-only /             (y := 1) compiles: True   def f(a, /, b): True
 3. 3.9  dict | dict, str.removeprefix            {1: 1} | {2: 2}: True   removeprefix: True
 4. 3.10 match statement, zip(strict=), pairwise  match compiles: True   zip strict: True   pairwise: True
 5. 3.11 ExceptionGroup, except*, tomllib, Self   ExceptionGroup: True   except*: True   tomllib: True   typing.Self: True
 6. 3.12 f-string quotes, type stmt, def f[T]     f"{"a"}": True   type X = int: True   def f[T](x: T): True
 7. 3.12 itertools.batched, sys.monitoring        batched: True   sys.monitoring: True   TaskGroup (3.11): True
 8. 3.13 copy.replace, free threading, new REPL   not asserted: hasattr(copy, 'replace') differs between this library's 3.12 and 3.14 runners
 9. 3.14 t-strings, deferred annotations          not asserted: compiles('t"x"') likewise differs, so the page describes both releases
10. both what rows 8 and 9 prove                  a feature check is worth exactly the set of interpreters it ran on

Try it

  1. Write feature?(name, src) that compiles a snippet and prints whether this Ruby accepts it. Probe a&.b, a case/in, the {x:} shorthand and it — then two snippets from a Ruby that does not exist (a |> pipe operator, a def without end) to see SyntaxError reported rather than raised.
  2. Run a child Ruby (RbConfig.ruby) with -W:deprecated -e '"a" << "b"' and capture its stderr: print the chilled-string warning, then run the same with -W:no-deprecated and show stderr is empty. Then try --enable=frozen-string-literal and print the exception class instead.
Solution to 1

ruby_versions_what_changed_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: a feature probe. `feature?` compiles a snippet and says whether this
# Ruby accepts it -- including a snippet from a Ruby that does not exist.
def feature?(name, src)
  RubyVM::InstructionSequence.compile(src)
  puts format("%-28s accepted", name)
rescue SyntaxError
  puts format("%-28s rejected (SyntaxError)", name)
end

feature? "2.3 safe navigation",      "a&.b"
feature? "2.7 pattern matching",     "case 1\nin Integer then :int\nend"
feature? "3.1 hash shorthand",       "x = 1\n{x:}"
feature? "3.4 it parameter",         "[1].map { it }"
feature? "imaginary: pipe operator", "1 |> puts"
feature? "imaginary: def without end", "def f(x) x * 2"

Verified output of ruby_versions_what_changed_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

2.3 safe navigation          accepted
2.7 pattern matching         accepted
3.1 hash shorthand           accepted
3.4 it parameter             accepted
imaginary: pipe operator     rejected (SyntaxError)
imaginary: def without end   rejected (SyntaxError)

See also