Skip to content

freeze is shallow and raises FrozenError

Level: 201 · anyone who has seen # frozen_string_literal: true at the top of a file and wondered how far it reaches

One line: freeze makes one object immutable — every later mutation raises FrozenError, naming the object — but only that object: what it contains stays mutable unless you freeze it too (Ractor.make_shareable does that for you), integers, symbols and nil are born frozen, and the magic comment freezes every string literal in its file; Python has no freeze and gets immutability from the type — tuple, frozenset, str, MappingProxyType, dataclass(frozen=True) — with the same shallowness and one back door.

Any Ruby object can be frozen, and frozen? reports it. A frozen object rejects every change: << on a string, []= on a hash, a setter or instance_variable_set on an instance of your own class — all raise FrozenError, and the message says which object refused. Freezing is a property of the object, not of the variable, so a frozen array can still hold unfrozen strings that anyone may change; that is what "shallow" means, and Ractor.make_shareable exists to freeze a whole structure in one call because a Ractor may only share objects that cannot change. Some objects are frozen from birth — every Integer, Symbol, nil, true, false, Float and Range — and a string literal is the deliberate exception: in Ruby 4.0 it is chilled, mutable but flagged, unless the file opens with # frozen_string_literal: true. The program prints seven numbered groups; group 6 starts two child Rubies, one on a file with the comment and one without.

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

1. freeze, frozen?, and what the copies do
   s = "abc".dup; s.frozen?                           false
   s.freeze.frozen?                                   true
   s.freeze.equal?(s)  (freeze returns self)          true
   s.dup.frozen?                                      false
   s.clone.frozen?                                    true
2. FrozenError names the object
   s << "d"                                           FrozenError: can't modify frozen String: "abc"
   [1].freeze << 2                                    FrozenError: can't modify frozen Array: [1]
   {a: 1}.freeze[:b] = 2                              FrozenError: can't modify frozen Hash: {a: 1}
   FrozenError.ancestors.take(3)                      [FrozenError, RuntimeError, StandardError]
3. freeze is shallow
   nested = [[1]].freeze; nested.first << 2           [[1, 2]]
   nested.first.frozen?                               false
   nested << [3]                                      FrozenError: can't modify frozen Array: [[1, 2]]
4. Ractor.make_shareable freezes all the way down
   Ractor.shareable?(nested)  (before)                false
   after make_shareable: nested.first.frozen?         true
   Ractor.shareable?(nested)  (after)                 true
5. born frozen: immediates and ranges; a literal is not
   1.frozen?                                          true
   :a.frozen?                                         true
   nil.frozen?                                        true
   true.frozen?                                       true
   1.5.frozen?                                        true
   (1..2).frozen?                                     true
   "lit".frozen?  (chilled, not frozen)               false
   [].frozen?                                         false
6. # frozen_string_literal: true, in a child ruby
   $ ruby plain.rb
   "lit".frozen?                                      false
   s << "!"                                           "lit!"
   $ ruby magic.rb   (line 1 is the magic comment)
   "lit".frozen?                                      true
   s << "!"                                           FrozenError: can't modify frozen String: "lit"
7. a frozen object of your own class has no back door
   c = Config.new.freeze; c.host = "x"                FrozenError: can't modify frozen Config: #<Config>
   c.instance_variable_set(:@host, "x")               FrozenError: can't modify frozen Config: #<Config>
   c.frozen?                                          true

Reading the output

  • Row 1. freeze returns its receiver, so s.freeze.frozen? reads naturally. dup gives an unfrozen copy; clone copies the frozen state along with the object.
  • Row 2. The message is the same shape for every class — can't modify frozen String: "abc" — and names the object by its inspect. FrozenError is a RuntimeError and so a StandardError, which is why a bare rescue catches it.
  • Row 3. [[1]].freeze freezes the outer array only. The inner array still accepts <<, and only the outer one raises.
  • Row 4. Ractor.shareable? is false for the frozen outer array while its contents are mutable; Ractor.make_shareable freezes the inner array as well, and the structure becomes shareable.
  • Row 5. 1, :a, nil, true, 1.5 and 1..2 are frozen without anyone asking; a string literal is not — it is chilled, which the Ruby text library measures on its literals are chilled ↗ page — and an array literal is plainly mutable.
  • Row 6. The same program run twice by a child Ruby. Without the comment, "lit".frozen? is false and << works; with # frozen_string_literal: true on line 1, the literal is frozen and << raises. The comment reaches every literal in that file and no further.
  • Row 7. A frozen instance of your own class refuses its setter and instance_variable_set alike; there is no way around a frozen object short of dup.

Compared with Python

Python has no freeze: an object is immutable or not by type, so the twin's rows use tuple, frozenset, str, types.MappingProxyType and a frozen dataclass. copy.copy of a tuple returns the very same object, since there is nothing to protect by copying (row 1). The refusals are ordinary TypeErrors and AttributeErrors — plain Exceptions, with no frozen-specific class — whose messages this page does not key because CPython rewords them between releases (row 2). The shallowness is identical: a tuple holding a list lets the list grow (row 3), and there is no make_shareable — MappingProxyType is a read-only view of a dict whose values stay mutable and which reflects later changes to the dict, and collections.abc.Hashable is satisfied by ([1],) even though hash of it fails, the same one-level check (row 4). What is born immutable is hashable — numbers, strings, None, range — and a list is neither (row 5); there is no magic comment because every string literal is immutable already, and bytearray is the mutable string when you need one (row 6). The back door is row 7: dataclass(frozen=True) raises FrozenInstanceError (an AttributeError) from __setattr__, but object.__setattr__(c, "host", "x") goes straight past it — Python's frozen is a convention enforced by one method, Ruby's is a flag the interpreter checks on every mutation.

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

1. no freeze: immutability comes from the type
   t = (1, 2); hasattr(t, "freeze")                   False
   type(t).__name__                                   tuple
   copy.copy(t) is t  (an immutable is not copied)    True
   list(t)  (the mutable counterpart)                 [1, 2]
   tuple(list(t)) == t                                True
2. the errors are TypeError or AttributeError
   t[0] = 9                                           TypeError
   frozenset({1}).add(2)                              AttributeError
   MappingProxyType({"a": 1})["b"] = 2                TypeError
   [c.__name__ for c in TypeError.__mro__[:3]]        ['TypeError', 'Exception', 'BaseException']
3. a tuple is shallow too
   nested = ([1],); nested[0].append(2)               ([1, 2],)
   type(nested[0]).__name__  (still mutable)          list
   nested[0] = [9]                                    TypeError
4. no deep freeze; MappingProxyType is a read-only view
   mp = MappingProxyType(d); mp["k"].append(2)        [1, 2]
   d["z"] = 0; dict(mp)  (a view, not a copy)         {'k': [1, 2], 'z': 0}
   isinstance(([1],), Hashable)                       True
   hash(([1],))  (shallow, like freeze)               TypeError
5. born immutable: numbers, str, None, range; a list is not
   isinstance(1, Hashable)                            True
   isinstance("a", Hashable)                          True
   isinstance(None, Hashable)                         True
   isinstance(True, Hashable)                         True
   isinstance(1.5, Hashable)                          True
   isinstance(range(1, 3), Hashable)                  True
   s = "lit"; s[0] = "L"  (every str literal)         TypeError
   isinstance([], Hashable)                           False
6. no magic comment: a str literal is immutable already
   s = "lit"; s += "!"; s  (rebinds s)                'lit!'
   ba = bytearray(b"lit"); ba += b"!"; ba             bytearray(b'lit!')
7. dataclass(frozen=True) has a back door
   c = Config(); c.host = "x"                         FrozenInstanceError
   object.__setattr__(c, "host", "x"); c.host         'x'
   issubclass(FrozenInstanceError, AttributeError)    True

The dataclasses ↗ reference documents frozen=True and the FrozenInstanceError; the types ↗ reference documents MappingProxyType as a read-only view.

Try it

  1. Write deep_freeze(obj) that freezes obj and, when it is an Array or a Hash, everything inside it, recursively. Apply it to { list: [1, [2]], name: "n" }, print frozen? for the hash, the array, the inner array and the string, then try to append to the inner array, append to the string and add a key, printing each FrozenError. Finish with Ractor.shareable? on the result.
  2. Compare your deep_freeze with Ractor.make_shareable on a second copy of the same structure: do both leave Ractor.shareable? true? Then take a frozen string and print frozen? for dup, clone and clone(freeze: false) — which of the three is the way to get a mutable copy, and which keeps the frozen state on purpose?
Solution to 1

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

# Kata: deep_freeze freezes an object and, for arrays and hashes, everything
# inside it, recursively -- what a plain freeze leaves mutable.

def deep_freeze(obj)
  case obj
  when Array then obj.each { |e| deep_freeze(e) }
  when Hash then obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
  end
  obj.freeze
end

config = { list: [1, [2]], name: "n" }
deep_freeze(config)
puts "   config.frozen?             #{config.frozen?}"
puts "   config[:list].frozen?      #{config[:list].frozen?}"
puts "   config[:list][1].frozen?   #{config[:list][1].frozen?}"
puts "   config[:name].frozen?      #{config[:name].frozen?}"

attempts = {
  "config[:list][1] << 3" => -> { config[:list][1] << 3 },
  "config[:name] << \"!\""  => -> { config[:name] << "!" },
  "config[:extra] = 1"     => -> { config[:extra] = 1 }
}
attempts.each do |label, attempt|
  attempt.call
  puts "   #{label.ljust(26)} worked"
rescue FrozenError => e
  puts "   #{label.ljust(26)} #{e.class}: #{e.message}"
end
puts "   Ractor.shareable?(config)  #{Ractor.shareable?(config)}"

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

   config.frozen?             true
   config[:list].frozen?      true
   config[:list][1].frozen?   true
   config[:name].frozen?      true
   config[:list][1] << 3      FrozenError: can't modify frozen Array: [2]
   config[:name] << "!"       FrozenError: can't modify frozen String: "n"
   config[:extra] = 1         FrozenError: can't modify frozen Hash: {list: [1, [2]], name: "n"}
   Ractor.shareable?(config)  true

See also