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:

  1. No allocation on repeated use. :status is a pointer lookup; "status" is a heap allocation.
  2. Equality is a pointer comparison. sym1 == sym2 compares memory addresses. str1 == str2 compares 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How Async Communication Improves Developer Productivity

Interruptions are productivity killers. Async communication lets developers focus without constant context switching.

Read more

Spring Boot Application Secrets — Rotating Credentials Without Downtime

Credential rotation is a security requirement that most teams either skip or handle with a restart. Neither is acceptable. Here is how to rotate database passwords, API keys, JWT secrets, and third-party credentials in a running Spring Boot application.

Read more

Spring Boot Auto-Configuration — How It Works and How to Override It

Spring Boot auto-configuration applies sensible defaults without requiring explicit bean definitions. Understanding the @Conditional mechanism, loading order, and override patterns turns auto-configuration from a black box into a predictable system.

Read more

Amsterdam Backend Salaries Hit €100K. Here Is How Startups Avoid That Overhead

Your next backend hire in Amsterdam will probably cost you six figures before you even factor in the 30% ruling changes and mandatory benefits. That number used to be reserved for staff engineers. Now it's table stakes for anyone decent.

Read more