Frozen String Literals and Immutability in Ruby
Ruby strings are mutable by default. You can call << to append, gsub! to replace, or encode to transform them in place. That flexibility is handy, but it comes with a hidden cost: every time you write a string literal, Ruby allocates a new object. If your program creates thousands of identical string literals, that is thousands of separate objects the garbage collector has to track.
Frozen string literals give you a way to make those literals immutable. The frozen_string_literal pragma addresses that concern, and understanding when and why to use it will make your Ruby code faster and your intent clearer.
Key Takeaways
- Frozen string literals are a file-level setting, not a global runtime switch
- They reduce allocations by reusing immutable string objects
String#dupandString#clonegive you mutable copies when you need themfreezeworks on any object, not just strings- Freezing literals makes intent clearer when code should not mutate shared values
If you mainly want the practical rule, it is this: use frozen string literals by default, then copy a string only when you really need to mutate it. That keeps the code honest about where data changes and where it should stay fixed.
What the pragma does
Place # frozen_string_literal: true at the very top of a Ruby source file:
# frozen_string_literal: true
class Greeting
def initialize(name)
@name = name
end
def hello
"Hello, #{@name}!"
end
end
From that point in the file, all string literals are frozen — they become immutable String objects. If any code tries to modify one, Ruby raises a FrozenError:
# frozen_string_literal: true
greeting = "hello"
greeting << " world" # FrozenError: can't modify frozen String
The pragma only affects the file where it appears, so it is a local decision rather than a project-wide policy. That makes it easy to adopt gradually. You can turn it on for a single file, check the impact, and expand from there if the codebase benefits.
Why frozen strings improve performance
Without the pragma, Ruby allocates a new String object each time it encounters a literal. Even if two literals contain the same text, they are separate objects with different object IDs. This means every loop iteration, every method call, and every template render that touches a string literal creates a new allocation for the garbage collector to track:
# without frozen_string_literal
3.times do
puts "hello".object_id
end
# prints three different numbers
With the pragma, Ruby can reuse the same frozen object across the file, since immutable objects are safe to share. The object is allocated once and referenced everywhere, which reduces both memory pressure and GC pause time. In applications that create large numbers of strings, such as template-heavy web apps or data processing scripts, this can add up to measurable performance gains:
# frozen_string_literal: true
3.times do
puts "hello".object_id
end
# prints the same number three times
Fewer allocations means less work for the garbage collector. In applications that create large numbers of strings — think template-heavy web apps or data processing scripts — this can add up to measurable performance gains.
It also makes reasoning about object identity a little easier. If a literal is frozen, you know the file is not trying to mutate it later, which removes one more moving part when you read the code.
What gets frozen and what doesn’t
The pragma freezes only static string literals — the things in quotes. Everything else stays mutable:
# frozen_string_literal: true
# These are frozen:
"hello"
'hello'
"hello #{10}" # interpolating a literal number is still static, thus frozen
# These are NOT frozen:
"hello #{name}" # dynamic — depends on runtime value
string = "hello" # the variable, not the literal
string.upcase # returns a new string (not the original)
In Ruby 3.0+, interpolated strings are always mutable, even with the pragma. Only the non-interpolated parts are frozen.
That nuance matters when you read a template or a helper method. A literal string is safe to reuse, but a string assembled from runtime values still behaves like an ordinary mutable object unless you explicitly freeze it yourself.
How FrozenError behaves
When Ruby 2.x encountered an attempt to modify a frozen string, it raised a plain RuntimeError. Starting with Ruby 3.0, this is a dedicated FrozenError:
# frozen_string_literal: true
s = "hello"
s << " world"
# FrozenError (Ruby 3.0+)
# RuntimeError (Ruby 2.x)
If you need to handle frozen string mutations, rescue FrozenError and create a new mutable string instead of forcing the old one to mutate. That keeps the error handler simple and avoids surprising side effects elsewhere in the program:
begin
s << " world"
rescue FrozenError
s = s + " world" # create a new mutable string instead
end
Mutating a frozen string
When you genuinely need a mutable version, String#dup creates a copy that you can modify freely. String#clone also works and preserves more object state such as singleton methods and the frozen status of the clone itself. For most everyday cases, dup is the clearer choice because it signals that you want a plain mutable copy without any extra baggage:
# frozen_string_literal: true
original = "hello"
copy = original.dup # copy is mutable
copy << " world"
puts copy # "hello world"
puts original # "hello" — unchanged
Using dup is the most common approach because it clearly signals that you want a separate mutable copy. clone preserves more object state, so it is useful in narrower cases where you care about that extra metadata. Both options are better than mutating the original literal when immutability is part of the design.
Freezing objects explicitly with freeze
The pragma only covers string literals in the file where it appears. For any other object — or for strings you want to freeze at runtime — call freeze directly:
config = {
api_key: ENV["API_KEY"],
endpoint: "https://api.example.com",
}.freeze
config[:api_key] = "new" # FrozenError
freeze works on any object, not just strings. Arrays, hashes, ranges, and custom objects can all be frozen to prevent accidental modification. When you freeze an array, any attempt to add, remove, or replace elements raises FrozenError:
array = [1, 2, 3].freeze
array << 4 # FrozenError
freeze is shallow, though. It freezes the object itself but not any objects it contains. The outer array cannot be modified, but the inner arrays are still mutable unless you freeze them separately. This is the main distinction to keep in mind when working with complex nested data structures:
frozen = [[1, 2], [3, 4]].freeze
frozen[0] << 99 # no error — inner arrays are not frozen
frozen << [5, 6] # FrozenError — the array itself is frozen
For deep freezing, you need to recursively freeze nested structures or use a gem like deep_freeze. If you pass around complex hashes or arrays, decide whether shallow immutability is enough or whether you need a deeper copy-and-freeze strategy.
Enabling globally
Instead of adding the pragma to every file, you can enable it project-wide with the --enable-frozen-string-literal flag. This applies the same freeze semantics to every file the Ruby interpreter loads, which gives you the memory and GC benefits across the entire application without annotating each source file individually:
ruby --enable-frozen-string-literal your_script.rb
Many Ruby performance guides recommend enabling it globally in production, since the memory and GC benefits apply to all strings throughout the application.
That said, global adoption is still a choice. Some codebases prefer to move file by file so they can spot where mutation really matters. The best rollout strategy is the one that matches your release process and your tolerance for refactoring.
Constants and immutability
A related concern is constant immutability. By default, constants holding mutable objects can be modified:
MAX_USERS = [100, 200, 500] # array is mutable
MAX_USERS << 1000 # no error
You can use the shareable_constant_value pragma to automatically freeze constant values:
# shareable_constant_value: literal
MAX_USERS = [100, 200, 500] # array is deeply frozen
MAX_USERS << 1000 # FrozenError
When you work with constants, the goal is the same as with frozen strings: make the code communicate intent. If a value is supposed to be shared and never mutated, freezing it makes that decision explicit. That is why frozen string literals fit so naturally with code that tries to keep state localized and easy to audit.
When to use the pragma
Use # frozen_string_literal: true in:
- All new Ruby files you write — it’s a good default habit
- Performance-critical code with many string literals
- Code you want to publish as gems (it was introduced to help gems prepare for Ruby 3.0)
The pragma has no real downside in most cases — if you’re not modifying string literals, making them frozen costs you nothing. The GC and allocation benefits are free immutability, and the phrase “frozen string literals” is often a useful shorthand for the general habit of treating literals as read-only.
If a file does need a lot of mutation, keep the pragma decision local to that file and document why. That way the exception is intentional, not accidental.
Common questions
Does frozen string literal mode change interpolated strings?
It changes how Ruby treats plain string literals in the file, but interpolated strings are still created from runtime values. The important part is that you should not assume every string in the file behaves the same way.
Should I use dup or clone?
Use dup in most everyday cases. Reach for clone only when you need the extra object state it preserves. For simple mutable copies, dup is easier to read and reason about.
Is global frozen string literal mode always the best choice?
Not always, but it is a strong default. If a codebase relies heavily on in-place string mutation, adopt it more gradually so you can catch the places that need to change.
See Also
- ruby-working-with-strings — full coverage of string manipulation in Ruby
- ruby-blocks-procs-lambdas — closures and how they capture variable bindings
- ruby-string-manipulation — string transformation techniques