Symbol vs String Keys: The Hash Bug That Hides in Plain Sight
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:
- Symbols are idiomatic for keys you control in code: options hashes, keyword-style arguments, configuration, fixed schemas. They are interned (one object per name, reused forever), so they are fast to compare and memory-cheap as repeated keys.
- Strings show up whenever data crosses a boundary:
JSON.parsereturns string keys, form params arrive as strings, CSV headers are strings, YAML can be either, and a database row’s attributes are strings.
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:
- Use symbol keys for hashes you build and control in code. Options, config, internal data structures.
- Expect string keys from anything parsed or received, and normalize once, at the boundary, to whatever your code expects.
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.