Object Lifecycle, Freezing and Copying

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, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. Every Ruby object has a life: it is created by a literal, by new or by one of a few lower-level routes, it is initialised, it is used (and perhaps copied), it may be frozen so that it can no longer change, it eventually becomes unreachable, and the garbage collector reclaims it, running any finalizer and clearing any weak reference on the way. This page follows that sequence: how objects are allocated and initialised, how dup and clone copy them and how a class customises copying, the difference between shallow and deep copies, what freeze does and does not do, the frozen string literal magic comment and the chilled strings of Ruby 3.4, deep freezing and Ractor-shareable constants, finalizers, weak maps and weak references, and object_id with the deprecation of ObjectSpace._id2ref in Ruby 4.0.

It builds on Classes and Objects (new and initialize), Variables, Constants and Scope (references versus objects, constants) and Literals (which literals are frozen). The equality side of identity, equal?, eql? and hash, is in Equality, Hashing and Comparison; immutable value objects built with Data are in Struct, Data and Value Objects; string mutability in depth is in Strings; hashes (whose string keys are copied and frozen) are in Hashes. Freezing is what makes data safe to share between threads and Ractors: see Threads and the GVL and Ractors. The collector itself, GC.stat and allocation tracing are in Garbage Collection and Memory; Marshal is covered with the other formats in Serialization: JSON, YAML, CSV and Marshal and its risks in Security. The errors raised here are part of Exceptions and Error Handling, and the traps that catch newcomers are collected in Common Mistakes and Gotchas. For other languages' treatment of the same ideas, see the Rust reference (ownership, Clone and Drop) and the Go reference (values, copies and no destructors).

Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The output shown in # => comments and in the output blocks is the real output. The examples use the shelf gem (a small library catalogue, see the shelf gem source) and are run with ruby -Ilib, so require "shelf" finds it. Where an address such as 0x0000000120348520 would appear in an object’s default inspect, the examples print only the stable part of the message, because the address changes on every run.

The life of an object

flowchart LR A["creation: literal, new, allocate, Marshal.load, dup, clone"] --> B["initialize, when created by new"] B --> C["in use: mutable"] C -->|"freeze"| D["frozen: cannot change"] C -->|"dup, clone"| E["copy: initialize_copy runs"] D -->|"dup, clone"| E C --> F["unreachable"] D --> F E --> C F --> G["GC: weak references cleared, finalizers run"] G --> H["memory reclaimed"]

Each stage has its own set of tools, and the sections below take them in order:

Stage What Ruby offers Section

Creation

Literals, Class#new, Class#allocate, Marshal.load, dup, clone, Data.define/Struct.new constructors

Allocation and initialisation

Copying

dup, clone, initialize_copy, initialize_dup, initialize_clone, Data#with, Marshal round trips

Copying objects

Freezing

freeze, frozen?, FrozenError, frozen string literals, chilled strings, Ractor.make_shareable, shareable_constant_value

Freezing objects, Frozen string literals and chilled strings, Deep freezing and shareable objects

Identity

object_id, equal?, ObjectSpace._id2ref (deprecated)

Object identity and object ids

Death

ObjectSpace.define_finalizer, ObjectSpace::WeakMap, ObjectSpace::WeakKeyMap, WeakRef, GC

Finalizers, Weak references

Allocation and initialisation

Class#new is two steps: it asks the class to allocate an empty instance with Class#allocate, then calls the private instance method initialize on it with the arguments it received (and the block). Allocation never calls initialize, so an allocated object has no instance variables and none of the invariants the constructor would establish. The value of initialize is ignored: new returns the allocated object, whatever initialize returned.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Ruby", author: Shelf::Author.new("Ada"))

# new = allocate + initialize
loan = Shelf::Loan.new(book, state: :lent)
blank = Shelf::Loan.allocate

p loan.instance_variables  # => [:@book, :@state]
p blank.instance_variables # => []
p blank.state              # => nil
p Shelf::Loan.private_instance_methods(false) # => [:initialize]
p Shelf::Loan.respond_to?(:allocate)          # => true

begin
  Integer.allocate
rescue TypeError => e
  puts e.message
end

begin
  Object.new(1)
rescue ArgumentError => e
  puts e.message
end

# Marshal.load builds objects with allocate, so initialize does not run.
class Counted
  def initialize = puts("initialize ran")
end

Counted.new
Marshal.load(Marshal.dump(Counted.new))
[:@book, :@state]
[]
nil
[:initialize]
true
allocator undefined for Integer
wrong number of arguments (given 1, expected 0)
initialize ran
initialize ran

Source: Class#allocate

The two initialize ran lines come from the two calls to Counted.new; the Marshal.load that follows prints nothing. Things to take from the example:

  • initialize is private (Loan.private_instance_methods(false) lists it), so it cannot be called on an existing object, and a second call is impossible by accident. BasicObject#initialize takes no arguments, which is why Object.new(1) raises ArgumentError when no class in the chain defines initialize.

  • Classes whose instances are immediate values or special objects have no allocator. Integer.allocate raises TypeError ("allocator undefined for Integer"), and Integer.new and Symbol.new do not exist (NoMethodError). Thread.current.dup fails for the same reason.

  • Marshal.load rebuilds objects with allocate and sets their instance variables directly, so initialize is not called. Validation done only in initialize is therefore skipped for data that comes from Marshal; this is one of the reasons to never load untrusted Marshal data. dup and clone also skip initialize (they call the copy hooks instead, see below). Data#with is different: it builds the new object through initialize with the merged members, so a validating initialize such as the one in Shelf::Isbn runs again.

  • A class can make new private (private_class_method :new, the basis of the singleton and factory patterns) or remove it (singleton_class.send(:undef_method, :new)). Core classes such as Integer and Symbol go further and remove the allocator, so neither new nor allocate can build a half-formed instance.

Ways in which an object comes into existence, and what runs:

Route What runs

A literal ("text", [1, 2], {a: 1}, 1..3, /re/)

The interpreter builds the object. No initialize is called for core literals.

Klass.new(args)

allocate, then initialize(args).

Data.define(:a).new(a: 1) or .new(1)

new converts positional to keyword arguments, then calls initialize with keywords; override initialize and call super to validate (as Shelf::Isbn does).

Klass.allocate

Only the allocator: no instance variables, no initialize.

obj.dup, obj.clone

An allocation of the same class, then the copy hooks (next section).

Marshal.load(data)

An allocation, then the stored instance variables are set; marshal_load or _load if the class defines them.

Class.new(superclass)

A new class object, which is itself an object with a lifecycle.

A conventional initialize stores its arguments, validates them and does no input or output: an object that needs a file, a socket or a database connection receives it as an argument, or opens it lazily. That keeps dup, Marshal and tests simple. A constructor that must never leave a half-initialised object can finish with freeze (see Building immutable objects).

Copying objects

Ruby variables hold references, so b = a copies the reference and both names point at one object. To get a new object you call dup or clone. Both produce a shallow copy: a new object of the same class whose instance variables refer to the same objects as the original’s. What differs is how much "extra" state is carried across:

State of the original dup clone

Instance variables (references)

copied

copied

Frozen state

not copied: the copy is unfrozen

copied: a frozen original gives a frozen copy

Singleton class (singleton methods)

not copied

copied

Modules added with extend

not copied

copied

Hook called

initialize_dup, then initialize_copy

initialize_clone, then initialize_copy

Keyword

none

freeze: true, freeze: false or freeze: nil (the default)

Think of dup as "make me another one like this" and clone as "make an exact replica, including how it was specialised". The documentation for Object#dup states the rule that modules added with extend are not copied by dup, and Kernel#clone that it "copies the frozen value state of obj, unless the :freeze keyword argument is given".

# frozen_string_literal: true

require "shelf"

module Auditable
  def audit = "audited #{self.class}"
end

store = Shelf::Store::Memory.new
def store.name = "main store"
store.extend(Auditable)
store.freeze

copy = store.dup
twin = store.clone
thawed = store.clone(freeze: false)

[["dup", copy], ["clone", twin], ["clone(freeze: false)", thawed]].each do |label, obj|
  puts "#{label.ljust(22)} frozen=#{obj.frozen?} " \
       "singleton=#{obj.singleton_methods.sort.inspect} " \
       "audit=#{obj.respond_to?(:audit)}"
end
p copy.singleton_class.include?(Auditable)  # => false
p twin.singleton_class.include?(Auditable)  # => true
dup                    frozen=false singleton=[] audit=false
clone                  frozen=true singleton=[:audit, :name] audit=true
clone(freeze: false)   frozen=false singleton=[:audit, :name] audit=true
false
true

Source: Object#dup and Kernel#clone

The singleton method name and the module Auditable survive clone (including clone(freeze: false)), and neither survives dup. The frozen state of the original survives clone unless freeze: false is passed.

The freeze: keyword of clone

clone accepts a freeze: keyword (freeze: false since Ruby 2.4, freeze: true since 3.0):

Argument Result

clone or clone(freeze: nil)

The copy is frozen if and only if the original is.

clone(freeze: true)

The copy is always frozen, even when the original is not (3.0).

clone(freeze: false)

The copy is never frozen, even when the original is. It is the supported way to get an unfrozen replica that keeps singleton methods and extended modules.

Any other value

ArgumentError ("unexpected value for freeze: Integer").

Immediate values and other objects that cannot change are their own copies: 1.dup and nil.dup return the receiver, 1.clone returns it too, and 1.clone(freeze: false) raises ArgumentError ("can’t unfreeze Integer"), as does :a.clone(freeze: false). Some objects cannot be copied at all: Thread.current.dup raises TypeError ("allocator undefined for Thread") and ENV.dup and ENV.clone raise TypeError (the latter since Ruby 3.2), telling you to use ENV.to_h.

The copy hooks

Neither dup nor clone copies instance variables itself in a way a class can intercept. Instead each allocates the copy, copies the instance variables, and calls hook methods on the copy, passing the original:

flowchart TD D["obj.dup"] --> A1["allocate an object of the same class"] A1 --> V1["copy instance variables"] V1 --> ID["initialize_dup(original)"] ID --> IC["initialize_copy(original)"] C["obj.clone(freeze: ...)"] --> A2["allocate, copy the singleton class"] A2 --> V2["copy instance variables"] V2 --> ICL["initialize_clone(original, freeze: ...)"] ICL --> IC IC --> Q{"clone, and the copy should be frozen?"} Q -->|"yes"| F["freeze the copy"] Q -->|"no"| R["return the copy"] F --> R D --> R2["dup never freezes the copy"]
Hook Role

initialize_copy(original)

The one to override in almost every case. Called on the new object after its instance variables were copied. Called by both dup and clone. Its return value is ignored.

initialize_dup(original)

Called by dup before initialize_copy. The default implementation calls initialize_copy.

initialize_clone(original, freeze: nil)

Called by clone before initialize_copy; receives the freeze: keyword exactly as given (3.0). The default calls initialize_copy.

The three hooks are private. Overriding initialize_dup or initialize_clone is only needed when dup and clone must behave differently (for example, a class that shares a connection when cloned but opens a new one when duplicated). Whatever you override, call super, so that the chain of defaults, and the copy logic of the core class you may inherit from, still runs.

# frozen_string_literal: true

require "shelf"

# A store that reports every step of copying.
class AuditedStore < Shelf::Store::Memory
  def initialize_dup(source)
    puts "initialize_dup"
    super
  end

  def initialize_clone(source, freeze: nil)
    puts "initialize_clone(freeze: #{freeze.inspect})"
    super
  end

  def initialize_copy(source)
    puts "initialize_copy from #{source.class}"
    super
  end
end

store = AuditedStore.new
puts "-- dup"
store.dup
puts "-- clone"
store.clone
puts "-- clone(freeze: true)"
p store.clone(freeze: true).frozen?
puts "-- clone(freeze: false) of a frozen store"
p store.freeze.clone(freeze: false).frozen?
-- dup
initialize_dup
initialize_copy from AuditedStore
-- clone
initialize_clone(freeze: nil)
initialize_copy from AuditedStore
-- clone(freeze: true)
initialize_clone(freeze: true)
initialize_copy from AuditedStore
true
-- clone(freeze: false) of a frozen store
initialize_clone(freeze: false)
initialize_copy from AuditedStore
false

Source: Object#dup

Both dup and clone end in initialize_copy, dup through initialize_dup and clone through initialize_clone. In the last step the original was frozen (freeze returned the store), freeze: false was passed down to initialize_clone, and the copy came out unfrozen. Proc#dup and Proc#clone call these hooks too (since 3.3), and Proc#dup returns an instance of the subclass (since 3.2).

Writing initialize_copy

initialize_copy is where a class copies the parts of its state that must not be shared. The rules:

  • It runs on the new object; the argument is the original. Reassign the instance variables that need their own copy (@books = @books.dup), do not mutate the original’s.

  • Call super first, so that the superclass’s copying runs. For Object the default does nothing visible, but a class that inherits from Hash, Array, String or Set relies on super to copy the contents.

  • The return value is ignored, so @books.dup as the last expression achieves nothing.

  • Calling it directly on an existing object is an error in practice: it raises TypeError ("initialize_copy should take same class object") if the argument has a different class, and FrozenError if the receiver is frozen.

  • Do not use it to validate; use initialize. Do not do input or output in it.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Ruby", author: Shelf::Author.new("Ada"))

# Object#dup copies the instance variable references, not the array they point to.
original = Shelf::Store::Memory.new
copy = original.dup
copy.add(book)
p original.all.size # => 1: both stores share one @books array
p copy.equal?(original) # => false

# A subclass that gives every copy its own array.
class IndependentStore < Shelf::Store::Memory
  def initialize_copy(source)
    super
    @books = @books.dup
  end
end

store = IndependentStore.new
twin = store.dup
twin.add(book)
p store.all.size # => 0
p twin.all.size  # => 1
1
false
0
1

Source: Hash#initialize_copy (one of many core implementations)

Shelf::Store::Memory#all returns @books.dup for the same reason: handing out the internal array would let callers modify the store.

How core classes copy

The core classes implement initialize_copy and follow the same shallow rule, with a few details worth knowing:

  • Array#dup, Hash#dup and Set#dup copy the container, not the elements. The copy has the same class as the original, so a subclass of Array or Hash duplicates to the same subclass.

  • Hash#dup keeps the default value and the default proc, and compare_by_identity mode. A frozen hash duplicates to an unfrozen one.

  • String#dup produces a plain, unfrozen, unchilled string even when the original is a frozen literal or chilled (Frozen string literals and chilled strings), which is why "literal".dup is the portable way to get a mutable string.

  • Struct#dup is shallow: the copy shares the member values.

  • Data instances are always frozen: dup and clone both return frozen copies, and clone(freeze: false) on a Data object still returns a frozen one, because Data freezes its instances. Use Data#with to derive a modified copy.

  • Range objects are frozen when created from a literal or with Range.new (3.0), but dup returns an unfrozen copy, as for every class.

  • Class#dup and Module#dup copy the methods and constants; the copy is a new class or module with its own identity.

  • Method#dup, Proc#dup, Binding#dup, IO#dup (a new file descriptor) and Thread::Mutex#dup work; Thread and ENV refuse to be duplicated.

Shallow and deep copies

A shallow copy is cheap and usually what you want: the copied collection can have elements added and removed independently, and the elements are values or immutable objects anyway. It becomes a bug when the shared elements are mutable and one holder changes them. Ruby has no built-in deep_dup (Rails adds one); the options are:

Technique Notes

initialize_copy

The right place for a class’s own state (see above). It copies exactly what the class knows must not be shared.

Marshal.load(Marshal.dump(obj))

Deep copy of plain data, preserving shared references and cycles. Fails (TypeError) for anything that cannot be dumped: Proc, Method, IO, Thread::Mutex, objects with singleton methods, and anonymous classes. Slow, and it creates new objects through allocate, so initialize is not called. Marshal.load also accepts freeze: true (3.1) to freeze everything it builds (strings deduplicated), which turns a round trip into a frozen deep copy.

A recursive helper

Fastest and most explicit when you know the data shapes, as in the example below. Handle cycles yourself or avoid them.

Ractor.make_shareable(obj, copy: true)

Deep-copies, then deep-freezes the copy; the original is untouched (see Deep freezing and shareable objects).

# frozen_string_literal: true

require "shelf"

shelf = { "novels" => [+"Dune", +"Emma"], "poetry" => [+"Odes"] }

shallow = shelf.dup
shallow["novels"] << +"Ulysses"
shallow["essays"] = []
p shelf.keys           # => ["novels", "poetry"]
p shelf["novels"].size # => 3: the arrays are shared

# 1. Marshal round trip: deep, but only for plain data.
deep = Marshal.load(Marshal.dump(shelf))
deep["novels"].first << "!"
p shelf["novels"].first # => "Dune"

begin
  Marshal.dump(proc { 1 })
rescue TypeError => e
  puts e.message
end

# 2. A recursive helper for the shapes you know.
def deep_dup(value)
  case value
  when Hash then value.to_h { |k, v| [deep_dup(k), deep_dup(v)] }
  when Array then value.map { |v| deep_dup(v) }
  when String then value.dup
  else value
  end
end

copy = deep_dup(shelf)
copy["poetry"] << +"Songs"
p shelf["poetry"] # => ["Odes"]

# 3. Ractor.make_shareable(copy: true) deep-freezes a deep copy.
frozen_copy = Ractor.make_shareable(shelf, copy: true)
p frozen_copy.frozen?, frozen_copy["novels"].first.frozen?
p shelf.frozen?, shelf["novels"].first.frozen?
["novels", "poetry"]
3
"Dune"
no _dump_data is defined for class Proc
["Odes"]
true
true
false
false

The dup of the outer hash shares the arrays, so "Ulysses" appears in the original’s list while the new key essays does not. The Marshal copy and the helper copy are independent, and the Ractor.make_shareable(..., copy: true) result is frozen all the way down while the original is untouched. Security reminder: Marshal.load runs arbitrary constructors for data it did not create, so use it for copying your own objects, never for input from outside the process; RuboCop’s Security/MarshalLoad cop deliberately allows the exact Marshal.load(Marshal.dump(x)) idiom for that reason.

Freezing objects

freeze marks an object as immutable and returns the object itself. After that, every operation that would change the object’s own state raises FrozenError (a subclass of RuntimeError, since Ruby 2.5), and frozen? returns true. There is no unfreeze: the only way back is a copy (dup, or clone(freeze: false)). Freezing is a flag on one object. It has three limits that explain most surprises:

  • It is shallow. The elements of a frozen Array and the values of a frozen Hash are separate objects with their own frozen state. [+"a"].freeze.first << "b" works.

  • It protects the object’s state, not its name. A variable that refers to a frozen object can still be reassigned to another object. Constants can be reassigned too (with a warning); freeze has nothing to do with that.

  • It is not reversible, and it applies to the receiver only. A frozen object’s copies are not frozen (except through clone), and freezing a class does not freeze its instances.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")

suffix = 1
literals = {
  "Integer" => 1, "Symbol" => :a, "nil" => nil, "true" => true,
  "Float" => 1.5, "Range" => (1..3), "Regexp" => /a/,
  "literal String" => "abc", "interpolated" => "a#{suffix}",
  "Array" => [1], "Data" => isbn, "Struct" => Shelf::Config.new(nil, 14)
}
literals.each { |name, value| puts "#{name.ljust(15)} #{value.frozen?}" }

def attempt(label)
  yield
rescue FrozenError => e
  puts "#{label}: #{e.message}"
  p e.receiver.class
end

attempt("string") { "abc" << "d" }
attempt("array") { Shelf::Loan::STATES << :lost }
attempt("ivar") { isbn.instance_variable_set(:@digits, "0") }
attempt("data") { isbn.instance_eval { @digits = "0" } }

list = %w[a b].freeze
p list.equal?(list.freeze)    # => true: freeze returns self
p list.dup.frozen?            # => false
p list.map(&:upcase).frozen?  # => false
p list.first.frozen?          # => true (the literal elements are frozen strings)
Integer         true
Symbol          true
nil             true
true            true
Float           true
Range           true
Regexp          true
literal String  true
interpolated    false
Array           false
Data            true
Struct          false
string: can't modify frozen String: "abc"
String
array: can't modify frozen Array: [:available, :lent, :overdue, :returned]
Array
ivar: can't modify frozen Shelf::Isbn: #<data Shelf::Isbn digits="9780306406157">
Shelf::Isbn
data: can't modify frozen Shelf::Isbn: #<data Shelf::Isbn digits="9780306406157">
Shelf::Isbn
true
false
false
true

Which objects are frozen without you asking:

Object Frozen?

Integer, Float, Symbol, nil, true, false

Always: they are immediate values or immutable objects.

Range

Always since 3.0, including Range.new and endless ranges.

Regexp literals

Since 3.0. Regexp.new results are not frozen.

String literal

Only with # frozen_string_literal: true. Otherwise "chilled" in 3.4 and later (not frozen, warns on mutation under -W:deprecated).

Interpolated String ("a#{x}")

Never, even with the magic comment (since 3.0).

Symbol#name

Frozen (3.0). Symbol#to_s returns a new unfrozen (chilled since 3.4) string.

nil.to_s, true.to_s, false.to_s

Frozen, shared strings.

Data instances

Always frozen, even after dup.

Struct instances, Array, Hash, Set, Object

Never, unless you freeze them.

Proc, Method, Time, classes and modules

Not frozen by default. Module#freeze stops reopening the class (see below).

Hash string keys

A string key that is not frozen is copied and the frozen copy is used as the key, so changing the original later cannot corrupt the table.

Notes on the behaviour of frozen objects:

  • Reading is always fine. instance_variable_get, inspect, ==, iteration and dup work on frozen objects.

  • Even a no-op mutation raises. [].freeze.clear and "AAA".freeze.upcase! (which would change nothing) raise FrozenError; the check happens before the operation.

  • Instance variables and singleton methods count as state. Assigning an instance variable, define_singleton_method and extend on a frozen object all raise. Memoization with @x ||= ... therefore fails on a frozen object, and a frozen object’s singleton class is frozen too.

  • FrozenError#receiver returns the object that refused to change, which is what a rescue handler or a test asserts on.

  • You can hook freezing. freeze is an ordinary method; a class can override it (calling super) to freeze its collaborators, which is a legitimate way to get "freeze my parts too". Thread::Queue#freeze and Thread::SizedQueue#freeze raise TypeError (3.3) because a frozen queue would be useless.

  • Frozen state is per object. Isbn.new(...) called twice yields two distinct frozen objects; freezing is not interning. String#-@ (alias dedup, 3.2) returns a frozen, deduplicated string, which is what to use for many repeated strings.

Freezing configuration and classes

# frozen_string_literal: true

require "shelf"

Shelf.configure { |config| config.loan_days = 21 }
Shelf.config.freeze

begin
  Shelf.configure { |config| config.loan_days = 7 }
rescue FrozenError => e
  puts e.message.split(": ", 2).first
end
p Shelf.config.store.frozen? # => false: freeze is shallow

# Freezing a class stops reopening it, which also seals its constants.
class Plugin
  VERSION = 1
end
Plugin.freeze

begin
  Plugin.class_eval { def run = :ok }
rescue FrozenError => e
  puts e.message
end

begin
  Plugin.const_set(:LIMIT, 3)
rescue FrozenError => e
  puts e.message
end

p Plugin.new.frozen? # => false: instances are not frozen with their class
can't modify frozen Shelf::Config
false
can't modify frozen Class: Plugin
can't modify frozen Class: Plugin
false

Source: Module#freeze

Shelf.config is a Struct, so freeze stops further assignments to its members, but the store it holds is a separate object and stays mutable: shallow again. Freezing a class (or module) forbids adding methods, constants and class-level instance variables to it, but its instances are ordinary objects. Freezing a library’s own classes at the end of loading, to prevent late monkey patching, is rare but legal; refinements and monkey patching are covered in Refinements and Monkey Patching.

Building immutable objects

There are three levels of "immutable" in Ruby code, and Data is the one the language gives you ready-made:

  1. A frozen collection of frozen elements (%w[a b].freeze with the magic comment, or deep_freeze).

  2. A class that freezes itself at the end of initialize: copy and freeze each mutable argument, then freeze. No method may assign an instance variable afterwards, including memoization.

  3. Data.define (3.2): instances are frozen on creation, with derives a modified copy, ==/eql?/hash are value-based. See Struct, Data and Value Objects.

# frozen_string_literal: true

require "shelf"

# A mutable class made immutable by freezing at the end of initialize.
class Reservation
  attr_reader :title, :tags

  def initialize(title, tags)
    @title = title.dup.freeze
    @tags = tags.map(&:freeze).freeze
    freeze
  end

  # Memoizing writes an instance variable, so it fails once frozen.
  def label
    @label ||= "#{title} [#{tags.join(', ')}]"
  end

  # The functional update: build a new frozen object.
  def with_tags(*more) = self.class.new(title, tags + more)
end

res = Reservation.new("Dune", %w[novel])
begin
  res.label
rescue FrozenError => e
  puts e.message[/\A[^:]+/]
end
p res.with_tags("classic").tags # => ["novel", "classic"]
p res.tags                      # => ["novel"]

# Data does the same for you, and #with is the functional update.
isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new("Herbert"))
renamed = book.with(title: "Dune Messiah")
p book.title, renamed.title
p renamed.author.equal?(book.author) # => true: with is a shallow copy
can't modify frozen Reservation
["novel", "classic"]
["novel"]
"Dune"
"Dune Messiah"
true

Source: Data#with

The memoization failure is the typical cost of freezing at the end of initialize: compute the value eagerly, in initialize, or keep the cache outside the object (a WeakKeyMap, see below). Note that Data#with is shallow: the renamed book shares its author with the original, which is safe only because Author is itself a Data.

Reasons to prefer immutable objects: they can be shared freely between threads and Ractors without locks, they are safe to use as hash keys, copies can be avoided (a function that receives a frozen argument cannot be surprised by later changes), and an invariant established in initialize stays true. The cost is allocation when you "change" them.

Frozen string literals and chilled strings

Strings are the one core type whose literals can be mutable or frozen, and the rules changed in Ruby 3.4.

Mode Behaviour

# frozen_string_literal: true (magic comment, first comment section of the file)

Every non-interpolated literal in that file is frozen and deduplicated. Mutating it raises FrozenError. This is what library code uses and what RuboCop’s Style/FrozenStringLiteralComment enforces.

# frozen_string_literal: false

Literals are ordinary mutable strings, with no warning.

No comment (Ruby 3.4 and later, including 4.0)

Literals are chilled: frozen? returns false and mutation works, but the first mutation emits a deprecation warning if deprecation warnings are enabled, because the literal will be frozen in a future major version. Frozen string literals are still not the default in 4.0.

--enable=frozen-string-literal or --enable-frozen-string-literal

Command-line switch: freeze literals in files without the comment (the comment in a file takes precedence over the switch).

--disable=frozen-string-literal or --disable-frozen-string-literal

Makes literals plain mutable strings, with no warning (3.4).

--debug-frozen-string-literal

With a warning or FrozenError, also reports where the string was created (3.4: "info: the string was created here").

The warnings appear only when the :deprecated warning category is on: -w, -W:deprecated or Warning[:deprecated] = true. A program run without them behaves as before, which is why chilled strings can be introduced without breaking anything, and why a test suite should turn the warnings on (or run once with RUBYOPT=-W:deprecated) to find the mutations before the default changes.

# rubocop:disable Style/FrozenStringLiteralComment
# No frozen_string_literal comment: literals are "chilled" in Ruby 3.4+.
title = "Dune"
p title.frozen?                 # => false
title << " Messiah"             # warns under -W:deprecated, still works
p title

copy = "Emma".dup
copy << "!"                     # no warning: dup returns a plain String
owned = +"Odes"
owned << "!"                    # no warning: +@ duplicates a chilled string
p copy, owned

name = :dune.to_s
name << "!"                     # Symbol#to_s is chilled as well
p name
# rubocop:enable Style/FrozenStringLiteralComment

The chilled literal is flagged on its first mutation; dup, the unary plus +@ (which returns the string itself if it is mutable and a copy if it is frozen or chilled, since 3.4), String.new and a string with interpolation never are. Symbol#to_s is chilled in the same way since 3.4. Running the script:

$ ruby chilled.rb
false
"Dune Messiah"
"Emma!"
"Odes!"
"dune!"
$ ruby -W:deprecated chilled.rb
false
chilled.rb:5: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
"Dune Messiah"
"Emma!"
"Odes!"
chilled.rb:15: warning: string returned by :dune.to_s will be frozen in the future
"dune!"

Source: NEWS for 3.4

$ ruby -W:deprecated --debug-frozen-string-literal chilled.rb
false
chilled.rb:5: warning: literal string will be frozen in the future
chilled.rb:3: info: the string was created here
"Dune Messiah"
"Emma!"
"Odes!"
chilled.rb:15: warning: string returned by :dune.to_s will be frozen in the future
"dune!"
$ ruby --enable=frozen-string-literal chilled.rb
true
chilled.rb:5:in '<main>': can't modify frozen String: "Dune" (FrozenError)

The rubocop:disable comment above is the deliberate exception that lets the file stay without the magic comment, as Style/FrozenStringLiteralComment would otherwise require it. The magic comment in a file wins over the command-line switch: with # frozen_string_literal: false the literals stay mutable even under --enable=frozen-string-literal.

How to write code that works in all modes:

  • Put # frozen_string_literal: true in every file and build mutable strings with ""`, `String.new` or `dup` (`+String.new("text") and "text".dup give an unfrozen string that is not chilled). << onto a string built by interpolation works because interpolated strings are never frozen.

  • Use +@ (+str) when a method must mutate an argument that may be frozen: it copies only when needed, which replaces the old str.dup if str.frozen? pattern.

  • Use -str (String#-@, alias dedup) to intern a long-lived string such as a hash key or a constant.

  • "lit".freeze is compiled to a shared frozen string; RuboCop’s Style/RedundantFreeze flags a freeze that the magic comment already makes redundant, and Style/MutableConstant asks that constants assigned a mutable literal be frozen.

In 4.0 the string returned by Symbol#to_s is chilled (a frozen result is announced for a later release); use Symbol#name, which returns a frozen string, when a read-only name is enough.

Deep freezing and shareable objects

Because freeze is shallow, a constant such as GENRES = %w[novel poetry].freeze is only half protected unless the literals inside are frozen too (the magic comment takes care of that case). For nested structures there are three tools:

  • A recursive helper walks the structure and freezes everything, as in the example below. It is explicit and easy to adapt, but it must handle every type you use (and cycles).

  • Ractor.make_shareable(obj) freezes obj and everything reachable from it (elements, instance variables, hash keys and values, Struct members), and returns obj. With copy: true it first makes a deep copy, so the original objects stay unfrozen. Ractor.shareable?(obj) answers whether an object is already deeply frozen. The Ractor API is documented as experimental ("the specification and implementation of this method are not mature and may be changed"), but make_shareable is widely used as the standard deep freeze, and does not by itself start a Ractor or print the experimental warning that Ractor.new prints.

  • # shareable_constant_value: is a magic comment that makes the constants of a file deeply frozen (3.0).

# frozen_string_literal: true

require "shelf"

# Object#freeze is shallow: the elements stay mutable.
genres = [+"novel", +"poetry"].freeze
p genres.frozen?, genres.first.frozen? # => true, false
genres.first << "s"
p genres # => ["novels", "poetry"]

# A recursive deep freeze for plain data.
def deep_freeze(value)
  case value
  when Hash then value.each { |k, v| deep_freeze(k) && deep_freeze(v) }
  when Array, Set then value.each { |v| deep_freeze(v) }
  end
  value.freeze
end

config = { "loan_days" => 14, "branches" => [+"north", +"south"] }
deep_freeze(config)
p config["branches"].first.frozen? # => true

# Ractor.make_shareable freezes everything reachable (and follows ivars).
Branch = Struct.new(:name, :tags)
branch = Branch.new(+"north", [+"main"])
p Ractor.shareable?(branch) # => false
Ractor.make_shareable(branch)
p Ractor.shareable?(branch), branch.tags.first.frozen? # => true, true

# A proc is shareable only if it captures nothing from its surroundings,
# and its self must be shareable too (it defaults to nil).
counter = 0
begin
  Ractor.shareable_lambda { counter += 1 }
rescue ArgumentError => e
  puts e.message
end
double = Ractor.shareable_lambda { |x| x * 2 }
p Ractor.shareable?(double), double.call(21)
true
false
["novels", "poetry"]
true
false
true
true
can not make a Proc shareable because it accesses outer variables (counter).
true
42

A Proc can be made shareable only if it does not capture local variables from its surroundings (it reads nothing it did not receive), and its self must be shareable. Ractor.shareable_proc and Ractor.shareable_lambda, new in Ruby 4.0, build such procs: self is replaced by nil unless self: is given. Ractors and the rules for sharing objects between them are explained in Ractors.

The shareable_constant_value magic comment

# shareable_constant_value: MODE (Ruby 3.0, documented as experimental for some modes) controls what happens to the value assigned to every constant that follows it in the file, until the comment appears again:

Mode Effect on CONSTANT = value Result of assigning a hash that holds an unfrozen local variable

none (default)

Nothing: no freezing, no check.

not frozen; source unchanged

literal

Literals directly assigned (and literals nested in them) are deeply frozen; any other value must already be shareable, or Ractor::IsolationError is raised.

Ractor::IsolationError

experimental_everything

Every value is made shareable with Ractor.make_shareable, which freezes the original objects.

frozen, source frozen as well

experimental_copy

Every value is deeply copied and made shareable (as make_shareable(value, copy: true)), leaving the original unfrozen.

frozen copy, source stays unfrozen

Module#const_set is not affected by the comment. The results in the last column were produced by running a small script with each mode (CATALOG.frozen?, CATALOG["north"].frozen? and source.frozen? print false false false for none, true true true for experimental_everything and true true false for experimental_copy; literal fails on that assignment with the message below):

$ ruby mode_literal.rb
mode_literal.rb:5:in 'RubyVM::FrozenCore.ensure_shareable': cannot assign unshareable object to CATALOG (Ractor::IsolationError)
	from mode_literal.rb:5:in '<main>'

The literal mode is the safe, readable choice for constants made of literals, and it makes the explicit .freeze calls (and RuboCop’s Style/MutableConstant complaints) unnecessary:

# frozen_string_literal: true
# shareable_constant_value: literal

module Shelf
  GENRES = %w[novel poetry essay]
  LIMITS = { loan_days: 14, renewals: 2 }
end

p Shelf::GENRES.frozen?, Shelf::GENRES.first.frozen?
p Shelf::LIMITS.frozen?
true
true
true

experimental_everything is dangerous with constants that come from other libraries (it would deep-freeze the library’s own object), and both experimental modes may change; prefer literal in new code. Deep freezing has a cost on data that really has to change: freeze what is configuration or reference data, not working state.

Object identity and object ids

Every object has an identity, obj.equal?(other), and an integer object_id that is unique among live objects and stable for the lifetime of an object. In current Ruby the id is allocated from a counter the first time it is requested, not derived from the memory address, so successive new ids are small multiples of eight (16, 24, 32) and the value says nothing about where the object lives. Equal objects that are distinct (two Shelf::Author values with the same name) have different ids; immediate values have fixed ones (nil is 4 and true is 20 in 4.0, and n.object_id is 2 * n + 1 for small integers). equal? compares identity; ==, eql? and hash compare values and are covered in Equality, Hashing and Comparison.

ObjectSpace._id2ref(id) converts an id back to the object. It is deprecated in Ruby 4.0 (Feature #15408): it prints ObjectSpace._id2ref is deprecated when deprecation warnings are enabled, and the garbage collector no longer maintains the id-to-object table until the method is first used, which makes object_id allocation and sweeping faster. It cannot work for objects that can be collected: an id whose object is gone raises RangeError ("16 is a recycled object"), and a number that was never an id raises it too ("is not an id value"). Replace it by keeping the object in your own registry, ideally a weak one:

# frozen_string_literal: true

require "shelf"

author = Shelf::Author.new("Ada")
id = author.object_id
p id.class # => Integer
p author.object_id == id # => true: stable for the object's lifetime
p Shelf::Author.new("Ada").object_id == id # => false: equal but a different object

# Deprecated in 4.0: turning an id back into an object.
p ObjectSpace._id2ref(id).equal?(author) # => true, with a warning under -w

# Keep your own registry instead; its values are held weakly.
registry = ObjectSpace::WeakMap.new
registry[id] = author
p registry[id].equal?(author) # => true
Integer
true
false
id2ref.rb:12: warning: ObjectSpace._id2ref is deprecated
true
true

Without -w the deprecated call prints nothing and returns true, so the warning is the only visible change in 4.0; code that still calls it will break when the method is removed.

Finalizers

A finalizer is a callable that Ruby invokes some time after an object has become garbage. It is a last resort for releasing a resource that the program failed to release explicitly (a file descriptor held by a C extension, a temporary file). ObjectSpace.define_finalizer(obj, callable) registers it; ObjectSpace.undefine_finalizer(obj) removes all finalizers of obj. The callable receives the object’s object_id, never the object (which is already gone). A block can be given instead of the callable. The call returns [0, callable].

# frozen_string_literal: true

# A resource whose cleanup must not depend on the object itself.
class Lease
  # The finalizer is built in a class method, so its proc cannot capture self.
  def self.releaser(name)
    proc { |id| puts "released #{name} (object_id #{id.class})" }
  end

  def initialize(name)
    @name = name
    ObjectSpace.define_finalizer(self, self.class.releaser(name))
  end
end

lease = Lease.new("room-1")
other = Lease.new("room-2")
ObjectSpace.undefine_finalizer(other)
puts "holding #{lease.class} and #{other.class}"

at_exit { puts "at_exit handler" }
puts "end of script"

begin
  ObjectSpace.define_finalizer("text".dup, :not_callable)
rescue ArgumentError => e
  puts e.message
end

begin
  ObjectSpace.define_finalizer(42, proc {})
rescue ArgumentError => e
  puts e.message
end

begin
  ObjectSpace.define_finalizer(Object.new.freeze, proc {})
rescue FrozenError => e
  puts e.message[/\A[^:]+/]
end
holding Lease and Lease
end of script
wrong type argument Symbol (should be callable)
cannot define finalizer for Integer
can't modify frozen Object
at_exit handler
released room-1 (object_id Integer)

The rules that make finalizers hard to use correctly:

  • The callable must not refer to the object. If it captures self (for example a proc created in an instance method that uses an instance variable), the object is reachable from the finalizer table and can never be collected; it is then finalized only at exit. Ruby warns "finalizer references object to be finalized" when the callable’s receiver is the object. Build the proc in a class method (as Lease.releaser does), or use a small callable class that stores the needed data (the two patterns recommended by the documentation).

  • Timing is unspecified. A finalizer may run long after the object became unreachable, on any thread, between any two operations, or never before the process ends. Programs must not depend on it for correctness. The one guarantee is that remaining finalizers run when the process exits, after the at_exit handlers, as the output shows.

  • Do not rely on state. The finalizer runs when other objects may already have been finalized, and it runs with the VM in a delicate state: avoid locks and long operations, and do not allocate large structures.

  • Errors are not propagated. An exception in a finalizer is not raised in your code; since Ruby 3.1 it is printed to standard error unless $VERBOSE is nil.

  • What cannot have a finalizer. Immediate values (cannot define finalizer for Integer), frozen objects (FrozenError), and a callable that does not respond to call (wrong type argument Symbol (should be callable)).

  • ObjectSpace.garbage_collect and GC.start request a collection but guarantee nothing about when finalizers run.

The better tools for deterministic cleanup are the block forms: File.open(path) { |file| ... } closes the file when the block ends, Mutex#synchronize unlocks, and your own methods can do the same with ensure:

# frozen_string_literal: true

# Deterministic cleanup: acquire, yield, release in ensure.
def with_lease(name)
  lease = acquire(name)
  yield lease
ensure
  release(lease) if lease
end

Use a finalizer only as a safety net behind an explicit close or release, and keep the explicit path as the one every caller is expected to use. Frameworks that manage native memory (C extensions, FFI pointers) are the typical users; see C Extensions, FFI and WebAssembly.

Weak references

A strong reference keeps an object alive; a weak reference does not. Ruby provides three classes for them:

Class What it holds weakly Comparison and notes

ObjectSpace::WeakMap

Both keys and values. An entry disappears when either is collected.

Keys compare by identity (equal?), so a string "a" and another "a" are different keys. Immediate values are allowed as keys. Has [], []=, key?, delete (3.3), keys, values, size, each.

ObjectSpace::WeakKeyMap (3.3)

Only keys; values are held strongly.

Keys compare with eql? and hash, like a Hash, so equal objects collapse into one entry. Supports [], []=, delete, key?, clear and getkey. Not Enumerable (entries come and go during iteration). Keys must be garbage collectable: symbols and integers raise ArgumentError.

WeakRef (library weakref)

One referent, wrapped in a proxy

WeakRef.new(obj) delegates to obj; weakref_alive? says whether it is still there, and a call on a collected referent raises WeakRef::RefError.

# frozen_string_literal: true

require "shelf"

# WeakMap: keys and values are weak, and keys compare by identity.
ids = ObjectSpace::WeakMap.new
key = +"978-0-306-40615-7"
ids[key] = Shelf::Author.new("Ada")
p ids.key?(key) # => true
p ids.key?(+"978-0-306-40615-7") # => false: another object
p ids[key] # => #<data Shelf::Author name="Ada">

# WeakKeyMap: keys compare with eql? and hash, values are strong.
canon = ObjectSpace::WeakKeyMap.new

intern = lambda do |isbn|
  existing = canon.getkey(isbn)
  next existing if existing

  canon[isbn] = true
  isbn
end

first = intern.call(Shelf::Isbn.new("978-0-306-40615-7"))
again = intern.call(Shelf::Isbn.new("9780306406157"))
p first.equal?(again) # => true: one canonical instance while it is alive

begin
  canon[:symbol] = true
rescue ArgumentError => e
  puts e.message
end

require "weakref"
ref = WeakRef.new(+"a string held weakly")
p ref.weakref_alive?
p ref.upcase
true
false
#<data Shelf::Author name="Ada">
true
WeakKeyMap keys must be garbage collectable
true
"A STRING HELD WEAKLY"

The WeakKeyMap is a canonicaliser (interning table): getkey returns the instance that is stored as the key, which is how the example makes two Isbn values with the same digits share one object while any caller still holds it. The lambda returns the stored key if there is one and registers the argument otherwise.

Typical uses and cautions:

  • Caches that must not leak: associate computed data with an object (a parsed form, a thumbnail) without keeping the object alive: WeakMap[obj] = data. The data lives as long as obj does, provided data does not itself refer to obj (a value that references its key keeps the entry alive in a WeakKeyMap).

  • Instance registries and interning: WeakKeyMap for canonical instances of value objects, as above.

  • Observers and listeners: hold subscribers weakly so that forgetting to unsubscribe does not leak.

  • Never use a weak structure for data you cannot recompute. An entry can disappear at the next collection, so every lookup must handle a miss and rebuild the value.

  • Output from size, keys and inspect of a weak map depends on the collector and is not reproducible; test the behaviour you control (presence while a strong reference is held) and not collection itself.

Where older books and tutorials are outdated

Older teaching Current behaviour

"String literals are frozen by default since Ruby 3.4"

They are not. Ruby 3.4 made literals chilled (mutation warns only under -W:deprecated); the magic comment or --enable=frozen-string-literal is still required for frozen literals, and 4.0 did not change the default.

Fixnum and Bignum as separate classes

There is one Integer class (since 3.2 the old names are gone); small integers are immediates, dup returns the receiver, and clone(freeze: false) raises.

ObjectSpace._id2ref(id) as a way to look up objects

Deprecated in 4.0. Use a registry (a WeakMap) instead.

"Always call dup before mutating a string you were given"

Prefer +@ (+str), which copies only when the string is frozen or chilled, and design APIs that do not mutate arguments.

Hash#inspect as {:a=>1} and undefined method messages with a backtick

{a: 1} since 3.4 and ' quotes: the error text in this page is the 4.0 text. A FrozenError message reads can't modify frozen String: "abc".

object.taint, untaint and trust

Removed in 3.2. There is no taint flag to copy or clear any more.

clone always keeps frozen state

True, and since 3.0 clone(freeze: true) and clone(freeze: false) override it; initialize_clone receives the freeze: keyword.

Struct and Data subclassed as class X < Struct.new(...)

Prefer X = Data.define(...) do ... end for immutable values; keyword_init: true is not needed for keyword construction (3.2).

OpenStruct for ad hoc records

A bundled gem in 4.0 and slow; use Struct, Data or a Hash.

A Set needs require "set"

Set needs no require (autoloaded since 3.2); a frozen set and Set#dup behave like other containers.

Common exceptions and warnings

Exception or warning Cause

FrozenError ("can’t modify frozen Foo: …​")

Mutating a frozen object (string, array, hash, instance variable, constant table of a frozen class). Subclass of RuntimeError; #receiver is the object. Fix by copying (dup, +str) or by not freezing.

TypeError ("allocator undefined for Thread")

dup, clone or allocate on a class that cannot be copied or allocated.

TypeError ("no _dump_data is defined for class Thread::Mutex", "can’t dump IO", "singleton can’t be dumped", "can’t dump anonymous class …​")

Marshal.dump of something that is not plain data, used as a deep copy.

TypeError ("initialize_copy should take same class object")

Calling initialize_copy with an object of another class.

ArgumentError ("can’t unfreeze Integer")

clone(freeze: false) on an immediate or always-frozen object.

ArgumentError ("unexpected value for freeze: Integer")

A freeze: argument other than nil, true or false.

ArgumentError ("cannot define finalizer for Integer")

define_finalizer on an immediate value. A frozen object raises FrozenError.

ArgumentError ("WeakKeyMap keys must be garbage collectable")

A symbol, integer or other immediate used as a WeakKeyMap key.

Ractor::IsolationError ("cannot assign unshareable object to CATALOG")

A constant assigned a non-shareable value in shareable_constant_value: literal mode, or an unshareable self: given to Ractor.shareable_proc.

ArgumentError ("can not make a Proc shareable because it accesses outer variables (counter).")

A proc passed to Ractor.shareable_proc or shareable_lambda that captures a local variable.

WeakRef::RefError

Using a WeakRef whose referent was collected.

warning: literal string will be frozen in the future

Mutating a chilled literal under -W:deprecated.

warning: string returned by :a.to_s will be frozen in the future

Mutating the result of Symbol#to_s.

warning: finalizer references object to be finalized

The finalizer’s receiver is the object; it can never be collected.

warning: ObjectSpace._id2ref is deprecated

Using _id2ref under -w in 4.0.

Pitfalls

  • dup and clone are shallow. A copy of a container shares its elements; copy them too or freeze them.

  • initialize_copy that mutates the original. The receiver is the copy and the argument is the original; assign the receiver’s variables, and call super.

  • Forgetting that dup drops singleton state (extended modules, define_singleton_method), and that clone keeps it and the frozen flag.

  • Freezing is shallow and not undoable. freeze a collection and its elements, or use Ractor.make_shareable.

  • Memoizing in a frozen object fails with FrozenError; compute in initialize, or store the cache elsewhere.

  • Mutating a string literal in a file with the magic comment raises; build strings with +"" or String.new, and use << on those.

  • Assuming chilled strings are frozen. They are not: "x".frozen? is false without the comment, and writing to them works (for now).

  • Finalizers that capture self keep the object alive forever; they also cannot be relied on for timing.

  • Using object_id as a key when you mean identity: use Hash#compare_by_identity (the keys are held strongly), or a WeakMap when the map must not keep the objects alive.

  • Marshal.load as a copy tool for untrusted data, and as a copy for objects with procs, IO, mutexes or singleton methods.

  • Constants are not frozen by being constants. Shelf::Loan::STATES is frozen because the source calls freeze; without it any caller could append.

RuboCop cops for this page

Cop What it checks

Style/FrozenStringLiteralComment

Requires the # frozen_string_literal: true magic comment (the 3.4 chilled-string migration step). Style always is the default.

Style/MutableConstant

A constant assigned a mutable literal (array, hash, string) must be frozen; works with the magic comment for strings. Unsafe autocorrect (adds freeze, which is shallow).

Style/RedundantFreeze

Flags freeze on objects that are already frozen (immediates, ranges, frozen-literal strings).

Security/MarshalLoad

Flags Marshal.load and Marshal.restore, except for the Marshal.load(Marshal.dump(x)) deep-copy idiom.

Version notes

Version Change

2.4

Object#clone(freeze: false); dup of Integer, Symbol, nil and other immediates returns the receiver instead of raising.

2.5

FrozenError (a subclass of RuntimeError) replaces the RuntimeError raised for frozen objects.

3.0

Range and Regexp literals frozen; interpolated strings no longer frozen by the magic comment; clone(freeze: true) and the freeze: keyword passed to initialize_clone; # shareable_constant_value:; Symbol#name; Ractor (experimental) with Ractor.make_shareable as a deep freeze.

3.1

Marshal.load(freeze: true). Exceptions raised in finalizers are printed to standard error.

3.2

String#dedup alias of -@; Data (frozen value objects); Proc#dup returns the subclass; ENV.clone raises TypeError; taint and untaint removed.

3.3

ObjectSpace::WeakKeyMap; ObjectSpace::WeakMap#delete; Proc#dup and Proc#clone call the copy hooks; Thread::Queue#freeze raises TypeError.

3.4

Chilled string literals (and Symbol#to_s) warn on mutation under -W:deprecated; --disable-frozen-string-literal; --debug-frozen-string-literal shows the creation site; String#+@ returns a copy for chilled strings.

4.0

ObjectSpace._id2ref deprecated; object_id is cheaper because the GC delays the id table until first use; Ractor.shareable_proc and Ractor.shareable_lambda. Frozen string literals are still not the default.