/ Tags: RUBY 3 / Categories: RUBY

Frozen String Literals in Production — Real Wins and the Gotchas Nobody Mentions

The # frozen_string_literal: true magic comment sits at the top of nearly every file in a modern Ruby codebase, added by RuboCop, accepted without much thought. It’s a genuine optimization — measurably fewer object allocations, less GC pressure — but it changes semantics in ways that surface as FrozenError at 3am rather than at the point you added the comment. Worth understanding what it does before you turn it on across a legacy application.

What the Comment Actually Does


Without it, every string literal creates a new mutable String object each time it’s evaluated.

Example:

def tag
  "invoice"
end

3.times { puts tag.object_id }
# => 60
# => 80
# => 100   (three distinct objects)

With the comment at the top of the file:

# frozen_string_literal: true

def tag
  "invoice"
end

3.times { puts tag.object_id }
# => 60
# => 60
# => 60   (one shared, frozen object)

The literal is deduplicated and frozen at parse time. Every evaluation returns the same object. In a method called ten thousand times a second, that’s ten thousand allocations that never happen.

Note the scope: the comment is per-file, must appear before any code (comments and a shebang are fine above it), and only affects literals. Strings built with interpolation, String.new, +, or dup are unaffected and remain mutable.

The Measurable Win


The gain is real but proportional to how string-heavy the code is.

Example:

require "benchmark/ips"
require "objspace"

def unfrozen_path(id) = "/api/v1/invoices/" + id.to_s

FROZEN_PREFIX = "/api/v1/invoices/"
def frozen_path(id) = FROZEN_PREFIX + id.to_s

Benchmark.ips do |x|
  x.report("literal each call") { unfrozen_path(42) }
  x.report("hoisted constant")  { frozen_path(42) }
  x.compare!
end

On a typical Rails request path, enabling frozen literals across an application usually shows up as a low single-digit percentage improvement in throughput and a more noticeable reduction in minor GC runs. It’s not transformative. It is free, and it compounds across a large codebase.

The bigger practical benefit is often correctness rather than speed: a frozen string can’t be mutated by a distant caller, which eliminates a category of action-at-a-distance bug.

Where It Breaks


Everything that mutates a string in place now raises.

Example:

# frozen_string_literal: true

buffer = ""
buffer << "hello"
# => FrozenError: can't modify frozen String: ""

name = "ada"
name.upcase!
# => FrozenError

path = "/tmp"
path.concat("/cache")
# => FrozenError

The fixes are straightforward once you know the shapes:

Example:

buffer = +""              # unary plus returns a mutable copy
buffer = String.new       # explicit, clearer to some readers
buffer = "".dup           # works on all versions

buffer << "hello"         # fine

The +"string" unary operator is the idiomatic form. Its counterpart -"string" returns a deduplicated frozen version, which is occasionally useful for hash keys built at runtime.

The subtle one: strings you return
# frozen_string_literal: true

class Report
  def header
    "Invoice Report"
  end
end

report = Report.new
title = report.header
title << " — 2026"   # FrozenError, raised in the caller's code

The failure surfaces far from the file that has the magic comment. A library that returns frozen literals and a caller that mutates the return value is the single most common way this bites in practice, and the trace points at the caller, not the cause.

The Gotchas Nobody Mentions


1. Frozen strings and encoding

force_encoding mutates the receiver, so it raises on a frozen literal:

# frozen_string_literal: true
data = "café"
data.force_encoding("ASCII-8BIT")   # FrozenError
data = data.dup.force_encoding("ASCII-8BIT")   # fine

Anything reading binary data from a socket or a file is a candidate. This bites in gems that handle protocol framing.

2. Interpolation is never frozen
# frozen_string_literal: true
name = "ada"
greeting = "Hello, #{name}"
greeting.frozen?   # => false

That’s by design — an interpolated string can’t be deduplicated at parse time. It also means the optimization does nothing for interpolation-heavy code, which is most view logic. If you’re chasing allocations in a hot path, hoist the static parts into constants rather than assuming the magic comment covered it.

3. Default arguments
# frozen_string_literal: true
def append_to(buffer = "")
  buffer << "x"     # FrozenError when called with no argument
end

Mutable defaults were always a questionable pattern, but plenty of code relies on them. Write buffer = +"" here.

4. frozen? is not the same as immutable content

Freezing is shallow. A frozen array of strings still allows the strings to be mutated; a frozen string’s bytes can’t change but a frozen Struct can hold mutable members. Don’t confuse “frozen” with “deeply immutable.”

5. It’s per-file, so it’s inconsistent by default

Half your files have the comment and half don’t. Whether "foo" is frozen depends on which file you’re reading, which is a real cognitive cost. Either apply it everywhere via RuboCop’s Style/FrozenStringLiteralComment and enforce it in CI, or don’t bother — the halfway state is the worst of both.

Pro-Tip: Before enabling this across a legacy codebase, run the test suite with RUBYOPT="--enable-frozen-string-literal" to freeze literals globally, without touching a single file. Every FrozenError that surfaces is a real mutation site, and you get the complete list in one run instead of discovering them one production incident at a time. Ruby also offers --debug-frozen-string-literal, which appends the file and line where the frozen string was created to the error message — that’s the missing piece when the error fires three gems away from the literal. Do this in CI as a separate job before you commit to the migration, and you’ll know the size of the job in ten minutes.

Chilled Strings and the Road Ahead


Ruby 3.4 introduced “chilled” strings to make this migration less painful. In a file without the magic comment, string literals behave as if frozen but emit a deprecation warning when mutated instead of raising. Run with warnings enabled and you get the same inventory the RUBYOPT trick produces, without the crash.

The direction of travel is clear: frozen-by-default is the intended endpoint. Writing new files with the comment and treating mutation of a literal as a code smell puts you on the right side of that transition, whenever it lands.

When to Skip It


There’s one legitimate case for leaving it off: code that does heavy in-place string building where the mutation is the point.

Example:

# Building a large document — mutation is intentional and fast
def render_csv(rows)
  out = +""
  rows.each { |row| out << row.join(",") << "\n" }
  out
end

That works fine with the comment enabled — you just write +"". So even here, the answer is usually “enable it and be explicit,” not “leave it off.” The genuine exception is generated code or a file where a hundred +"" prefixes would obscure the logic, which is rare enough that it’s not worth planning around.

Conclusion


# frozen_string_literal: true is a small, real performance win and a meaningful correctness improvement, and both come with a semantic change that fails loudly in places distant from the file you edited. Apply it uniformly rather than file by file, use +"" wherever you need a mutable buffer, and audit the whole codebase in one shot with RUBYOPT="--enable-frozen-string-literal" before committing. The migration is finite and the direction Ruby is heading makes it worth doing now rather than under time pressure later.

FAQs


Q1: Does the magic comment affect strings from other files?
No. It’s strictly per-file. A frozen literal returned from a file that has it will still be frozen when a file without it receives the object — freezing travels with the object, not the call site.

Q2: How do I check whether a specific string is frozen at runtime?
str.frozen? returns a boolean. In a console, "literal".frozen? reflects whether the enclosing context has the pragma, which for IRB depends on how you started it.

Q3: Does freezing strings save memory as well as time?
Yes — identical literals across a file are deduplicated to one object, so a file that mentions "active" fifty times holds one string instead of fifty. Across a large application the aggregate saving is meaningful, though rarely dramatic.

Q4: Is String#+@ (unary plus) available in older Ruby?
+"str" and -"str" were added in Ruby 2.3, the same version that introduced the magic comment. On anything older, use .dup.

Q5: Should gems enable frozen string literals?
Yes, and carefully — a gem returning frozen strings from its public API can break callers who mutate them. Either freeze internally and dup on the way out of public methods, or document that returned strings are frozen.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More