In Ruby, defining a constant feels like making a promise: “this won’t change.” But unless we take specific precautions, that promise doesn’t always hold. Ruby doesn’t enforce deep immutability—so constants can still reference objects that change under the hood.
In Ruby, defining a constant feels like making a promise: “this won’t change.” But unless we take specific precautions, that promise doesn’t always hold. Ruby doesn’t enforce deep immutability — so constants can still reference objects that change under the hood.
Let’s take a look
TAGS = [:news, :opinions]
Looks solid enough. But try this:
TAGS << :editorials
#=> [:news, :opinions, :editorials]
Even though TAGS
is a constant, the array it references is still mutable.
Freezing Helps – But Only at the Surface
To stop top-level mutations, Ruby gives us the #freeze
method:
TAGS.freeze
TAGS << :sports
#=> FrozenError: can't modify frozen Array
That works for shallow objects. But what happens when things get nested?
TAGS = {
news: {
world: %w[politics economy],
local: %w[crime weather]
},
opinions: %w[editorials columns]
}.freeze
Now, try this seemingly harmless method:
def title(tag:)
tag.titleize!
end
title(TAGS.dig(:news, :world, 0))
#=> "Politics"
That titleize!
method modifies the string in place—even though it came from a frozen constant.
TAGS.dig(:news, :world, 0)
#=> "Politics"
So what’s happening? The outer structure is frozen, but the nested strings aren’t. Mutation sneaks in from the inside.
A Note About Strings
Strings are useful for examples like this because they make it easy to show how deeply nested mutation can happen. They’re simple, and we use them often.
Ruby offers a handy safeguard: place the magic comment # frozen_string_literal: true
at the top of your file, and all string literals become immutable. Since Ruby 3.0, string hash keys are also frozen automatically. The community has an ongoing discussion about whether all strings in Ruby should be immutable by default.
A Better Approach: Enumerable#deep_freeze
To truly lock down complex structures, we need to freeze everything recursively. For some obscure reason Ruby doesn’t include a built-in #deep_freeze
method—but we can write one ourselves:
module DeepFreeze
def deep_freeze
each do |value|
if value.respond_to?(:deep_freeze)
value.deep_freeze
elsif value.respond_to?(:freeze)
value.freeze
end
end
freeze
end
end
Enumerable.include(DeepFreeze)
With this in place, we can do the following:
TAGS = {
news: {
world: %w[politics economy],
local: %w[crime weather]
},
opinions: %w[editorials columns]
}.deep_freeze
Any attempt to mutate any part of that structure will now raise a FrozenError
. That’s the kind of protection we expect from a constant.
A Hidden Gem: Ractor.make_shareable
Ruby’s Ractor
class includes a lesser-known class-method: make_shareable
. It freezes an object and all of its references to make it safe for multi-threaded use. But it also works well for general deep freezing.
Let's use it in our DeepFreeze
module:
module DeepFreeze
def deep_freeze
Ractor.make_shareable(self)
end
end
Enumerable.include(DeepFreeze)
It’s a clean, efficient way to make our data truly read-only.
A Quick Recap
Freezing just the surface isn’t enough when our constants contain deeply nested data.
If we’re working with config values, shared data structures, or anything that shouldn’t change once defined, deep freezing gives us confidence—and code we can trust.
(Note: The documentation currently states that the specification and implementation of make_shareable may change. Use it at your own discretion.)