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.)