Ruby Symbols vs Strings — When It Actually Matters in Production
by Eric Hanson, Backend Developer at Clean Systems Consulting
The question that keeps coming up wrong
On most Rails codebases I've inherited, symbols and strings are used interchangeably everywhere — hash keys, Redis keys, log tags — usually based on whoever wrote that file first. The common justification is "symbols are faster," which is true in a narrow sense and misleading in practice. The real story is about memory layout, object identity, and a few sharp edges that will bite you in a long-running process.
What actually makes them different
In MRI Ruby (the standard CRuby implementation), every String you create is a distinct heap object. Two strings with identical content are two separate allocations:
"status".object_id == "status".object_id # => false, every time
Symbols are interned — there is exactly one :status object for the lifetime of the process. The symbol table is a global hash map keyed by the symbol's name. That's it. The performance difference comes from two things:
- No allocation on repeated use.
:statusis a pointer lookup;"status"is a heap allocation. - Equality is a pointer comparison.
sym1 == sym2compares memory addresses.str1 == str2compares content, character by character.
In a tight loop doing hash lookups by key, symbols win on both counts. On synthetic benchmarks (single-threaded, 10M iterations, MRI 3.3, MacBook M2), symbol key lookup runs roughly 20–30% faster than string key lookup for short keys. That gap shrinks as key length grows, since string comparison fails fast on mismatches.
Where it matters in production
Long-running processes with dynamic symbol creation
This is the one case where symbols become a liability. Before Ruby 2.2, all symbols were immortal — they lived for the entire process lifetime. That changed with the introduction of dynamic symbols (those created via String#to_sym or :"#{interpolation}"), which became garbage-collectible. Literal symbols (:status, :user_id) remain immortal.
The risk: if you're calling .to_sym on user-supplied input or database column names in a loop, you can bloat the symbol table in a way that GC won't fix:
# Don't do this at scale
params.keys.each { |k| do_something(k.to_sym) }
On a Rails API with 200 unique query parameters across all endpoints, this is fine. On a webhook processor that ingests arbitrary JSON payloads from third-party systems, you can end up with thousands of interned symbols that stay allocated until the process restarts. Use Symbol.all_symbols.count in a console session to check if you've got symbol table bloat.
Hash-heavy internal data structures
For hashes you construct and own — configuration objects, option maps, internal event payloads — use symbols. You get faster lookups, more readable code, and no ambiguity about what key you're comparing against:
# Internal config hash — symbols are the right call
TIMEOUTS = {
connect: 2,
read: 30,
write: 10
}.freeze
For hashes that cross a serialization boundary — JSON, Redis, MessagePack, database results — you're dealing with strings. Don't fight it. ActiveRecord returns string-keyed hashes from connection.execute. JSON.parse returns string keys by default. Converting everything to symbols at the boundary just adds an allocation pass and a potential symbol table risk.
HashWithIndifferentAccess (Rails) and the symbolize_keys / deep_symbolize_keys helpers exist specifically because people kept mixing these. If you find yourself reaching for symbolize_keys constantly, the real fix is picking a convention at each layer boundary and sticking to it.
Pattern matching and case/when
Ruby's pattern matching (introduced in 3.0, stabilized in 3.1) uses === for comparison. Symbols and strings are not interchangeable here:
case response[:status]
in :ok then handle_ok
in :error then handle_error
end
If response[:status] is "ok" (a string), neither branch matches. This is a silent failure — no exception, just a miss. It's easy to introduce this bug when you normalize data at the boundary but forget one path. Pick your convention for internal event/result objects and enforce it with a type system or at least a test.
The frozen string literal pragma
Since Ruby 2.3, you can add # frozen_string_literal: true at the top of a file. All string literals in that file become frozen, immutable objects that Ruby can (and often does) deduplicate. This gives you a chunk of the symbol benefit for strings — fewer allocations, cheaper equality — without the symbol table risks.
# frozen_string_literal: true
STATUS_OK = "ok"
STATUS_ERROR = "error"
Rails has shipped with this pragma in generated files since Rails 6. If you're starting a new service, enable it globally via --enable-frozen-string-literal in .ruby-version tooling or by adding the comment to every file (Rubocop's Style/FrozenStringLiteralComment cop can enforce this automatically). It won't help for dynamically constructed strings, but it eliminates a whole class of unnecessary allocation for string constants.
The practical rule
Symbols for internal keys, method options, and named constants you control. Strings at serialization boundaries and anywhere user or external data drives key names. Never call .to_sym on unbounded external input. Enable frozen_string_literal globally on new projects.
If you're running a memory-sensitive service and want to verify you're not leaking symbols, add this to your metrics pipeline:
# Expose via /metrics or your APM agent
{ symbol_count: Symbol.all_symbols.count }
A stable or slowly growing symbol count across requests means you're clean. A monotonically increasing count under load means something is calling .to_sym on dynamic input somewhere.