← Back to blog

Symbol vs String Keys: The Hash Bug That Hides in Plain Sight

· Lachlan Young

Here is a Ruby gotcha that has cost nearly everyone who writes Rails a confused half hour:

user = { "name" => "Alice", "role" => "admin" }

user[:name]   # => nil
user["name"]  # => "Alice"

The hash clearly has a name. You asked for the name. You got nil. Nothing raised, nothing warned, the value is just quietly absent. The cause is that :name and "name" are two genuinely different objects, and a Ruby hash treats them as two genuinely different keys.

Why they are not the same key

A Symbol and a String are different types, and a Hash decides whether two keys match using eql? and hash, neither of which considers a symbol equal to a same-spelled string.

:name == "name"      # => false
:name.eql?("name")   # => false
:name.hash == "name".hash  # => false

So when you store under "name" and look up under :name, the hash computes a different bucket, finds nothing, and returns its default, which is nil. It is working exactly as designed. The trap is purely that :name and "name" look so similar that your eyes read them as the same thing while Ruby does not.

This is also why, if you ever see a hash diff where the two sides look identical but the test still fails, the very first thing to check is whether one side has symbol keys and the other has strings.

Where the mismatch comes from

You rarely write the bug directly. It sneaks in at the boundary between systems that prefer different key types:

require 'json'

config = { timeout: 30 }                 # symbol keys (you wrote it)
payload = JSON.parse('{"timeout": 30}')  # string keys (it was parsed)

config[:timeout]    # => 30
payload[:timeout]   # => nil   <-- the classic mismatch
payload["timeout"]  # => 30

Your code looks right, your data looks right, and they simply disagree about what a key is.

HashWithIndifferentAccess: the Rails escape hatch

Rails sees this problem so often that it ships a hash that does not care which type you use. ActiveSupport::HashWithIndifferentAccess stores keys as strings internally and lets you read them with either:

hash = { "name" => "Alice" }.with_indifferent_access

hash[:name]   # => "Alice"
hash["name"]  # => "Alice"

This is why params in a Rails controller just works no matter how you access it; params is a HashWithIndifferentAccess, not a plain Hash. It is genuinely convenient, but it is worth knowing what it costs: every key is converted to a string on the way in, so it is slower and allocates more than a plain hash. It is the right tool at the edges of your app where data is messy, and the wrong tool deep in a hot loop where you control the keys.

Plain Ruby has no built-in equivalent, so outside Rails you handle the mismatch explicitly.

Normalizing keys yourself

When you do not have ActiveSupport, the fix is to convert keys to one type as data enters your code. Ruby 2.5+ has transform_keys for exactly this:

payload = JSON.parse('{"timeout": 30, "retries": 3}')

symbolized = payload.transform_keys(&:to_sym)
symbolized[:timeout]  # => 30

For a quick one-off, symbolize_keys (Rails) or rolling your own works too, but transform_keys is plain Ruby and reads clearly. Going the other direction is just transform_keys(&:to_s).

A word of caution on transform_keys(&:to_sym) with untrusted input: in older Rubies, symbols were never garbage collected, so blindly symbolizing arbitrary external keys was a denial-of-service risk. Modern Ruby garbage-collects dynamically created symbols, so this is far less dangerous than it used to be, but the habit of normalizing to strings at trust boundaries is still a reasonable default.

A rule of thumb

You can sidestep almost all of this pain with one convention:

The key word is once. Convert the data the moment it enters your system, then work with a single, predictable key type everywhere downstream. The bugs come from leaving the two types to mingle and hoping you remember which is which at each access.

When the diff still surprises you

Even with good habits, symbol-vs-string mismatches surface in test failures, where an expected hash with symbol keys is compared against an actual hash with string keys. The two dump to your terminal looking nearly identical:

-{:name=>"Alice", :role=>"admin"}
+{"name"=>"Alice", "role"=>"admin"}

Same values, same spelling, but every single key differs by type. Scanning that by eye, it is easy to conclude the test is broken rather than the keys. This is one of the exact cases RubyHash is built for: paste both hashes and it shows you, key by key, that the values match and the types of the keys are what changed, so you know immediately to reach for transform_keys or with_indifferent_access instead of hunting for a value that moved.

Two characters of difference, : versus a pair of quotes, and a whole afternoon can hinge on noticing it. Once you internalize that symbols and strings are different keys, the mystery nils stop being mysterious.


Comparing Ruby hashes in a failing test and need to spot the difference fast? Try RubyHash, paste your two hashes for a clean, side-by-side diff.

Enjoyed this post?

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