The go-to resource for upgrading Ruby, Rails, and your dependencies.

How to Handle Frozen String Literals When Upgrading Legacy Ruby Apps


In the early days of typesetting, printers physically assembled individual metal letters into a frame. Once the frame was locked and the ink was applied, the page was printed. If an author wanted to change a single word, the printer couldn’t mutate the existing printed page — they had to construct an entirely new frame.

This physical constraint forced precision, but it also established a reliable system: once a page was minted, it was guaranteed not to change unexpectedly.

Similarly, a frozen string literal in Ruby acts like that locked frame of metal letters — once created, it cannot be altered without creating an entirely new object.

For years, Ruby took the opposite approach with its string objects. By default, every string in Ruby was mutable. You could pass a string into a method, and that method could alter the string in place, changing it for every other part of the system that held a reference to it. While convenient, this flexibility came with a significant cost: unexpected side effects and immense memory overhead, as Ruby had to allocate a new object for every identical string literal encountered during execution.

As the language matured, the Ruby core team recognized the need for a more efficient, predictable approach. Enter the frozen string literal. While this feature offers substantial performance benefits, handling frozen string literals when upgrading legacy Ruby apps presents a unique set of challenges that require a pragmatic, systematic approach.

The Conceptual Shift: Why Freeze Strings?

Before we get into the mechanics of upgrading, though, let’s take a step back and examine the philosophy behind frozen strings.

Ruby, strictly speaking, has always allowed you to freeze an object by calling .freeze on it. However, in older versions of Ruby, executing puts "hello" ten times inside a loop implicitly created ten distinct string objects in memory. The garbage collector then had to track and eventually clean up each of these objects.

We can actually observe this behavior using Ruby’s built-in object_id method in an interactive session:

$ irb
irb(main):001> "hello".object_id
=> 60
irb(main):002> "hello".object_id
=> 80

You also may notice that the object IDs are different. This is significant because it proves Ruby is allocating a brand new object every time the literal is evaluated, which means the garbage collector must eventually clean up both of these distinct objects.

When a string is frozen, however, it becomes immutable — it cannot be changed after it is created. If you freeze a string literal globally, Ruby can optimize it by creating a single object in memory and reusing it:

irb(main):003> "hello".freeze.object_id
=> 100
irb(main):004> "hello".freeze.object_id
=> 100

This reuse dramatically reduces memory allocations, decreases garbage collection pauses, and ultimately lowers cloud infrastructure costs for large-scale Rails applications.

Starting in Ruby 2.3, developers could opt into this behavior file-by-file using a magic comment:

# frozen_string_literal: true

The Ruby core team initially planned to make all string literals frozen by default in Ruby 3.0. While they eventually walked back that strict requirement to avoid breaking millions of lines of legacy code, the ecosystem has decisively moved toward immutability. Modern gems expect frozen strings, and tools like RuboCop enforce them by default.

The Structural Reality in Legacy Upgrades

When engineering teams execute a Ruby and Rails upgrade on an older codebase, they often encounter a deep web of technical debt related to string mutation.

Legacy Ruby applications frequently rely on methods that mutate strings in place. The shovel operator (<<), gsub!, upcase!, and strip! were long considered idiomatic ways to build or format strings efficiently.

If you append the # frozen_string_literal: true comment to the top of a ten-year-old Ruby file, you are almost guaranteed to trigger exceptions. When the Ruby interpreter encounters an attempt to mutate a frozen string, it immediately raises a FrozenError: can't modify frozen String.

Attempting to fix this by indiscriminately enabling frozen strings across a massive codebase is a recipe for broken CI builds and production downtime. Handling these changes safely requires a battle-tested workflow.

A Battle-Tested Workflow for Legacy Codebases

We will see throughout this process that introducing immutability is not a find-and-replace operation. It requires an iterative code audit.

1. Audit the Codebase via Runtime Execution

Static analysis tools, though often quite helpful, cannot catch every string mutation. Because Ruby is dynamically typed, it’s often impossible for a parser to know if a variable holds a frozen string literal or a dynamically generated (and thus, mutable) string until the code actually runs.

To find where your application relies on string mutation, you must lean on your test suite. You can force the Ruby interpreter to treat all string literals as frozen globally by passing an environment variable:

$ RUBYOPT="--enable-frozen-string-literal" bundle exec rspec

Tip: Since this command is run often during a major upgrade effort, many developers add a temporary alias to their ~/.bashrc or ~/.zshrc: alias rsf="RUBYOPT='--enable-frozen-string-literal' bundle exec rspec"

Running your test suite with this flag will instantly surface every location where your code attempts to mutate a frozen string, raising a FrozenError. You will likely see output similar to this:

F

Failures:

  1) UserFormatter formats the name correctly
      Failure/Error: name.upcase!
      
      FrozenError:
        can't modify frozen String: "john doe"
      # ./app/services/user_formatter.rb:4:in `format_name'
     # ...snip...

I’ve abbreviated the full stack trace above for the sake of brevity. However, this output provides a precise map of the technical debt hotspots you need to remediate. You also may notice that the exception output explicitly displays the value of the frozen string that failed — in this case, "john doe". This is extremely useful for tracing the error back to the exact literal or dynamically-frozen object that was passed into the mutating method.

2. Evaluate and Remediate Mutations

Once you have identified the failing locations, you must choose how to remediate them. There are three major approaches to resolving a FrozenError; depending on the particular circumstances you find yourself in, one of them will make more sense than the others.

Option 1: Switch to Non-Mutating Methods

This is the most common and generally preferred method. For example, upcase! is an imperative, mutating method — when you run it, it changes the internal state of the string object itself. This is fast, but it destroys the original string for any other part of your system holding a reference to it.

Non-mutating methods, on the other hand, leave the original object intact. Instead of modifying the existing string in place, you use a method like upcase that returns a brand new string entirely.

# Legacy, mutating approach (Will raise FrozenError)
def format_name(name)
  name.strip!
  name.upcase!
  name
end

# Modern, non-mutating approach
def format_name(name)
  name.strip.upcase
end

Option 2: Reassignment and Interpolation

If you are building a string dynamically, you can use reassignment or string interpolation instead of appending to the string object in place. By doing this, Ruby allocates a new string containing the combined text and points the sql variable to the new object, rather than trying to stuff more bytes into the original frozen literal.

# Legacy approach
sql = "SELECT * FROM users"
sql << " WHERE active = true" # Raises FrozenError

# Modern approach (Reassignment)
sql = "SELECT * FROM users"
sql += " WHERE active = true"

# Modern approach (Interpolation)
sql = "SELECT * FROM users"
sql = "#{sql} WHERE active = true"

Tip: While string interpolation also creates a new string object, it is often more readable than manual string concatenation using + or +=. Therefore, interpolation is generally favored by the Ruby community for building complex strings out of smaller pieces.

Option 3: Explicitly Unfreeze (The Unary Plus)

At times, you genuinely need a mutable string — perhaps you are building a massive text buffer where creating a new object on every loop iteration would cause memory bloat. In these specific, performance-critical loops, you can explicitly signal to Ruby that a string literal should be mutable by prefixing it with the unary plus operator (+), or by calling .dup on an existing frozen string.

# Explicitly creating a mutable, unfrozen string
buffer = +"" 
buffer << "First line\n"
buffer << "Second line\n"

# Alternatively, using dup
buffer = "".dup

One may wonder: if avoiding new object allocation is so important, why not explicitly unfreeze every string that raises a FrozenError?

The answer is straightforward: explicitly unfreezing strings universally defeats the memory optimization benefits that frozen string literals provide in the first place. By slapping a unary plus on every literal in your codebase, you revert to Ruby’s original, memory-heavy behavior.

Generally speaking, the first option (non-mutating methods) is the safest and most idiomatic. The third option (the unary plus) should be reserved strictly for specific loops where object allocation is a measured bottleneck.

3. Incremental Enforcement with RuboCop

Of course, we could add these magic comments by hand, but manual file-by-file updates on a large Rails app are prone to human error. Once you have remediated the runtime exceptions, you can use RuboCop to systematically enforce the magic comment across your application.

Before you run an automated correction command that modifies hundreds of files, however, it is wise to ensure the latest ‘known good’ version of your codebase is committed to source control.

Usage

The syntax for RuboCop’s autocorrect mode follows a standard Unix format:

$ bundle exec rubocop -A [options] [file|directory]

In its simplest form, you could run RuboCop against a specific file or directory without any restrictive options:

$ bundle exec rubocop -A app/models/

However, running a full auto-correction on a legacy directory will likely alter whitespace formatting, rename variables, and attempt to fix dozens of unrelated style violations simultaneously. Since we want to focus solely on frozen strings during an upgrade, we can layer in the --only flag to restrict its behavior:

$ bundle exec rubocop -A --only Style/FrozenStringLiteralComment app/models/

In this refined command:

  • -A (or --auto-correct-all) instructs RuboCop to automatically fix the issues it flags.
  • --only Style/FrozenStringLiteralComment restricts the tool to solely inserting the required magic comment at the top of the file.
  • app/models/ limits the scope to a single directory.

By applying this command iteratively — one directory at a time — you can ensure your CI builds remain green while paying down technical debt in manageable segments.

Long-Term Technical Health

Handling frozen string literals is rarely the most glamorous part of a Rails upgrade, but it is one of the most structurally significant.

By systematically addressing string mutations and embracing immutability, you are not quieting deprecation warnings or appeasing a linter. You are fundamentally optimizing your application’s memory profile. This technical debt remediation pays dividends long after the migration is complete, reducing memory allocations, lowering infrastructure costs, and ensuring your legacy Ruby application is prepared for the performance improvements of Ruby 3.3, Ruby 3.4, and beyond.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming