← Back to blog

Freeze Your Strings: Ruby's Most Underused Pragma

· Lachlan Young

There’s a magic comment in Ruby that most developers have seen, many copy-paste without thinking about, and few actually understand. It’s one line at the top of a file, and it changes how every string literal in that file behaves.

# frozen_string_literal: true

That’s it. One comment, and suddenly every string literal in the file is frozen. You can’t mutate them, you can’t append to them, you can’t gsub! them in place. Try it and you’ll get a FrozenError. And this is, overwhelmingly, a good thing.

What frozen actually means

When you write a string literal in Ruby without the pragma, every time that line of code executes, Ruby allocates a new String object. Even if the string is identical every single time.

def greeting
  "hello"  # new String object allocated on every call
end

a = greeting
b = greeting
a.object_id == b.object_id  # => false

Two different objects with the same content. Now add the pragma:

# frozen_string_literal: true

def greeting
  "hello"  # same frozen String object reused
end

a = greeting
b = greeting
a.object_id == b.object_id  # => true

Same object. No allocation. Ruby can safely reuse it because it knows nobody will mutate it.

Why this matters for performance

String allocation is one of the most common sources of object churn in Ruby applications. Every time you write a hash key as a string, a log message, a SQL fragment, a format specifier, you’re potentially creating a throwaway object that the garbage collector will need to clean up later.

In a Rails request cycle, this adds up. Hundreds of string literals, executed across dozens of files, on every single request. The frozen_string_literal pragma lets Ruby skip all those allocations for strings that never needed to be mutable in the first place.

Benchmarks vary, but teams that have adopted the pragma across large Rails applications consistently report measurable reductions in GC pressure and memory usage. It’s not going to 10x your app, but it’s free performance for one line of code.

Why this matters for correctness

Performance is nice, but the correctness argument is stronger. Consider this bug:

DEFAULT_STATUS = "pending"

def create_order(status: DEFAULT_STATUS)
  status << " (reviewed)"  # mutates the default!
  Order.create(status: status)
end

The first call works fine. The second call? DEFAULT_STATUS is now "pending (reviewed)", and it’ll keep accumulating on every call. This is a real category of bug that shows up in production, and it’s subtle enough to slip through code review.

With frozen strings, status << " (reviewed)" raises immediately. The bug is caught the first time the code runs, not after it’s corrupted your data.

# frozen_string_literal: true

DEFAULT_STATUS = "pending"

def create_order(status: DEFAULT_STATUS)
  status << " (reviewed)"  # => FrozenError!
end

Fail fast, fail loud. That’s what you want.

The gotchas

Frozen string literals aren’t quite “add the comment and forget about it.” There are a few things that trip people up.

String interpolation creates new (mutable) strings

# frozen_string_literal: true

name = "Alice"
greeting = "Hello, #{name}"
greeting.frozen?  # => false

Interpolated strings are new objects. They’re not frozen by the pragma. This is usually what you want, since the string is dynamically constructed, but it surprises people who expect everything to be frozen.

You need .dup or +"" for intentionally mutable strings

When you genuinely need a mutable string, you have two options:

# frozen_string_literal: true

# Option 1: .dup
buffer = "".dup
buffer << "data"  # works fine

# Option 2: unary plus
buffer = +""
buffer << "data"  # also works fine

The +"" syntax looks strange the first time you see it, but it’s idiomatic Ruby. The unary + on a frozen string returns an unfrozen copy. It’s the accepted convention.

Some gems don’t expect frozen strings

Older gems that mutate string arguments might break when you pass them frozen strings. This has gotten much rarer as the ecosystem has adapted, but it’s worth knowing about if you’re working with legacy dependencies.

How to adopt it gradually

You don’t have to add the pragma to every file at once. In fact, you probably shouldn’t. Here’s a practical approach:

  1. Add it to new files. Make it part of your team’s file template. Every new .rb file starts with the pragma.

  2. Add it to files you’re already touching. Working on a bug fix in user.rb? Add the pragma, run the tests, fix any breakage. You’re already in that file anyway.

  3. Use Rubocop to enforce it. The Style/FrozenStringLiteralComment cop can require the pragma in all files. Set it to warn initially, then enforce once you’ve cleaned up the backlog.

# .rubocop.yml
Style/FrozenStringLiteralComment:
  EnforcedStyle: always
  1. Batch convert low-risk files. Test files, configuration files, initializers, these are usually safe to convert in bulk because they rarely do string mutation.

Rails and the frozen future

Rails 7+ generates new applications with # frozen_string_literal: true in every file. The Rails team has been moving toward this for years, and it’s now the default for new projects.

Ruby itself has flirted with making frozen string literals the default behavior (without needing the comment), though that proposal has stalled a few times. Regardless of whether it becomes the language default, the direction is clear: frozen strings are the intended style.

If you’re starting a new project today, there’s no reason not to include the pragma in every file from day one. If you’re maintaining an existing project, the gradual adoption approach works well. Either way, you’re writing more correct, more performant code with a single line.

One line, two benefits

The frozen_string_literal pragma is rare in programming: a change that improves both performance and correctness simultaneously, with almost no downside. It prevents a real category of mutation bugs. It reduces garbage collection pressure. It makes your intent explicit. And it costs you exactly one line per file.

If you’re not using it yet, start with the next file you create.


Debugging Ruby hash diffs in your test output? Try RubyHash, paste your expected and actual hashes for a clean, side-by-side comparison.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.