Object Freezing and Immutability in Ruby — When to Lock Things Down
Mutability is the default in Ruby — most objects can be changed after creation, which is convenient right up until you have a hash shared between two parts of a system and one part changes it without the other expecting it. Ruby’s freeze method is the tool for opting out of mutability when you need immutable data. Understanding when freezing matters, what it actually prevents, and how frozen string literals interact with your codebase is more useful than treating immutability as a blanket rule.
What freeze Does — and Doesn’t Do
freeze prevents further mutation on an object. Once frozen, attempts to modify the object raise a FrozenError.
Example:
str = "hello"
str.frozen? # => false
str.upcase! # mutates in place — fine
str << " world" # fine
str.freeze
str.frozen? # => true
str.upcase! # => FrozenError: can't modify frozen String: "hello world"
str << "!" # => FrozenError
# Frozen check before modifying
str.dup.upcase! # dup creates an unfrozen copy — safe to mutate
Example:
# Arrays and hashes — freeze prevents structural changes
config = { host: "localhost", port: 5432 }.freeze
config[:host] = "production-db" # => FrozenError
config[:ssl] = true # => FrozenError
config.merge!(timeout: 30) # => FrozenError
# Reading still works
config[:port] # => 5432
The critical caveat: freeze is shallow. It freezes the object itself but not the objects it contains.
Example:
outer = ["hello", "world"].freeze
outer << "!" # => FrozenError — can't add to frozen Array
outer[0] << "!" # => "hello!" — inner string is not frozen, mutation succeeds
outer # => ["hello!", "world"] — the frozen array now contains a mutated string
Frozen String Literals
Strings are the most commonly frozen objects in Ruby applications, and the # frozen_string_literal: true magic comment at the top of a file makes every string literal in that file frozen by default.
Setup:
# frozen_string_literal: true
str = "hello"
str.frozen? # => true
str << "!" # => FrozenError
str.upcase! # => FrozenError
# But new strings from operations are not frozen
new_str = str + "!" # creates new unfrozen string — fine
new_str = str.upcase # creates new unfrozen string — fine
str += " world" # creates new string and rebinds str — fine
Two benefits: memory (string literals with the same value share one object instead of allocating new ones each time) and correctness (catches accidental in-place mutation of strings you meant to keep intact).
The gotcha to test for: code that uses << to build strings incrementally fails with frozen string literals.
Example:
# frozen_string_literal: true
# This breaks:
result = ""
items.each { |item| result << item } # => FrozenError on first iteration
# Fix — use + or join instead:
result = items.join("")
result = items.reduce("") { |acc, item| acc + item }
# Or use String.new for a mutable empty string:
result = String.new
items.each { |item| result << item } # String.new creates an unfrozen string
Constants and Freeze
Ruby constants aren’t truly immutable — they prevent rebinding the constant name, but not mutation of the object itself.
Example:
RATES = { usd: 1.0, eur: 0.92 }
# Ruby warns about rebinding a constant:
RATES = {} # => warning: already initialized constant RATES
# But mutation of the object is silent:
RATES[:gbp] = 0.79 # No warning, no error — RATES is mutated
RATES # => { usd: 1.0, eur: 0.92, gbp: 0.79 }
Example:
# Freeze constants that should be immutable
RATES = { usd: 1.0, eur: 0.92 }.freeze
STATUSES = %w[pending active suspended].freeze
TIMEOUT = 30 # Integers are already frozen in Ruby
RATES[:gbp] = 0.79 # => FrozenError — caught immediately
Freezing configuration hashes, status lists, and any constant that represents fixed data is a habit worth building. The error surfaces at assignment time instead of when some downstream code reads unexpected data.
Deep Freezing — When Shallow Isn’t Enough
When you need true immutability for nested structures, shallow freeze isn’t sufficient.
Example:
# Manual deep freeze with recursion
def deep_freeze(obj)
case obj
when Hash
obj.each_value { |v| deep_freeze(v) }
when Array
obj.each { |v| deep_freeze(v) }
when String
obj.freeze
end
obj.freeze
end
config = deep_freeze({ db: { host: "localhost", port: 5432 }, debug: false })
config[:db][:host] = "other" # => FrozenError — inner hash is frozen too
The ice_nine gem provides a more complete implementation that handles circular references and edge cases the manual approach misses. For most application code, shallow freeze on constants is sufficient; deep freeze is most useful for configuration objects that are shared across threads.
Ruby 3.2+ Data Class — Immutable Value Objects
Ruby 3.2 introduced Data, a class for defining immutable value objects. Unlike Struct, Data instances are frozen by default.
Example:
# Define an immutable value object
Point = Data.define(:x, :y)
Measure = Data.define(:amount, :unit)
point = Point.new(x: 3, y: 4)
point.frozen? # => true
point.x # => 3
point.x = 5 # => FrozenError — no setter defined
# Equality by value, not identity
Point.new(x: 3, y: 4) == Point.new(x: 3, y: 4) # => true
# Creating modified copies with `with`
scaled = point.with(x: point.x * 2) # => Point(x: 6, y: 4)
Example:
# Real-world: money as an immutable value object
Money = Data.define(:amount, :currency)
price = Money.new(amount: 29.99, currency: "USD")
discounted = price.with(amount: price.amount * 0.9)
# => Money(amount: 26.991, currency: "USD")
price.amount = 0 # => FrozenError — price is immutable
Data is ideal for coordinates, measurements, money, addresses, and any concept where two instances with the same values should be considered equal and neither should be modifiable.
Freeze and Ractors
Ruby’s Ractor model (experimental concurrency) requires that objects shared between ractors be immutable. Frozen objects are shareable; unfrozen objects raise Ractor::IsolationError when you try to pass them across a ractor boundary.
Example:
config = { db_url: ENV["DATABASE_URL"] }.freeze
ractor = Ractor.new(config) do |cfg|
# Can read cfg safely — it's frozen and shareable
puts cfg[:db_url]
end
Frozen strings, frozen hashes, frozen arrays, integers, symbols, and Data instances are all shareable across ractors by default.
Pro-Tip: Before adding
# frozen_string_literal: trueto a production file, run your test suite withRUBYOPT="--enable=frozen_string_literal"first. This flag applies frozen string literals globally across all files for the test run. Any test that breaks reveals exactly which string mutations need to be converted to non-mutating alternatives before you add the comment file by file.
Conclusion
Freezing in Ruby is a tool for expressing intent and catching bugs early. Frozen constants catch accidental mutation at the point of assignment. Frozen string literals save allocations and surface in-place mutation bugs during development. The Data class gives you immutable value objects without writing a freeze call yourself. None of these require a comprehensive immutability strategy — they’re targeted choices that pay off in specific places. Constants that represent fixed data, configuration objects, and anything shared across threads or ractors are the right starting points.
FAQs
Q1: Does freezing a string affect performance?
Frozen string literals improve performance by allowing string deduplication — multiple occurrences of the same literal share one object instead of allocating separately. The performance benefit is measurable in string-heavy code. The cost is any place that mutates strings in place with << needs to be changed to use + or String.new.
Q2: Can I unfreeze an object?
No. freeze is irreversible on the frozen object. To work with a mutable copy, use dup — it creates an unfrozen copy of the object (shallow copy). clone preserves the frozen state, so frozen_obj.clone.frozen? is true; frozen_obj.dup.frozen? is false.
Q3: Are symbols and integers frozen by default?
Yes — symbols, integers, floats, and nil, true, false are always frozen in Ruby. 1.frozen? is always true. You don’t need to freeze them manually.
Q4: Does freeze affect method calls that return new objects?
No. freeze only prevents in-place mutation. Methods that create and return new objects — upcase, +, map, select — work fine on frozen objects. The restriction is on bang methods (upcase!, <<, push) and direct attribute assignment that modify the receiver.
Q5: Should I add # frozen_string_literal: true to all files?
It’s worth doing, but incrementally. Test one file at a time, fix the << mutations, and add the comment. The Ruby core team and many popular gems already use it. The main friction is legacy code with string building patterns using <<. Running the test suite with RUBYOPT="--enable=frozen_string_literal" first gives you a complete picture of what needs changing before you commit to file-by-file rollout.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀