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¶
- Write
feature?(name, src)that compiles a snippet and prints whether this Ruby accepts it. Probea&.b, acase/in, the{x:}shorthand andit— then two snippets from a Ruby that does not exist (a|>pipe operator, adefwithoutend) to seeSyntaxErrorreported rather than raised. - 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-deprecatedand show stderr is empty. Then try--enable=frozen-string-literaland 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.
See also¶
itand numbered parameters — the 3.4 feature of row 13, in fullSetis a core class — row 14'sSet, measuredStructandData— 3.2'sData.definenext toStructcase/inpattern matching — the 2.7 feature of row 8- Ractors share nothing — 3.0's Ractor and 4.0's
Ractor::Port - Refinements — the 2.1 feature of row 3
- Strings are mutable — chilled literals, the 3.4 change of row 13
- Gems, Bundler and the Gemfile — how a library leaves the default set (3.3, 3.4) without leaving Ruby
- Ruby text: literals are chilled ↗ — the warning row 13 mentions, captured
- Arguments: positional, keyword, splat and block — A Ruby parameter list has eight kinds of slot
- YJIT and ZJIT are switches, not defaults — MRI (
RUBY_ENGINE == "ruby") always runs YARV bytecode through its interpreter; YJIT (since 3.1) and ZJIT…