Skip to content

A string key and a symbol key are two keys

Level: 201 · anyone whose config[:host] came back nil after JSON.parse

One line: {"a" => 1}[:a] is nil because "a" and :a are different objects of different classes, JSON.parse always gives string keys, and the literal {"a": 1} — quotes, then a colon — makes a symbol key; Python has one string type, so "a" and 'a' are the same key and the question never comes up.

A Hash finds a key by eql? and hash, and a String is never eql? to a Symbol. Ruby code written by hand uses symbol keys ({host: "x"}, keyword arguments, fetch(:port)); data that arrives from outside — JSON, YAML, CSV, a database row, ENV — comes with string keys, and the two meet at the boundary. JSON.parse(text, symbolize_names: true) or transform_keys(&:to_sym) converts on the way in; to_json converts symbols back to strings on the way out, so a round trip changes the key class. The trap for a Python reader is the literal {"a": 1}: the quotes look like a string key, but the colon after them makes a symbol, exactly as {a: 1} does, and Ruby quietly allows the quotes so that symbols with unusual characters ({"a-b": 1}) can be written. The program prints the lookups, both conversions, the trap, and the KeyError message that reveals which kind of key you asked for.

Verified output of string_and_symbol_keys_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. {"a" => 1}[:a] / ["a"]                         nil / 1
 2. {"a" => 1}.key?(:a) / key?("a")                false / true
 3. JSON.parse('{"a": 1, "b": [1, 2]}')            {"a" => 1, "b" => [1, 2]}  key class String
 4. JSON.parse(..., symbolize_names: true)         {a: 1, b: [1, 2]}  key class Symbol
 5. transform_keys(&:to_sym) / (&:to_s)            {a: 1} / {"a" => 1}
 6. {"a": 1}   (quotes, then a colon: the trap)    {a: 1}  key class Symbol
 7. {"a" => 1, :a => 2}.size                       2  {"a" => 1, a: 2}
 8. {a: 1} == {"a" => 1}                           false
 9. {a: 1}.to_json, then JSON.parse                {"a":1}  -> {"a" => 1}  key class String
10. :a.to_s / "a".to_sym / :a.frozen? / "a".frozen? "a" / :a / true / false
11. {"a-b": 1}  (a symbol that needs quoting)      {"a-b": 1}  key class Symbol
12. {"a" => 1}.fetch(:a)                           KeyError: key not found: :a  <- the message shows the symbol

Reading the output

  • Rows 1–2: the same hash, asked with a symbol and with a string. Only the string finds the value; key? agrees.
  • Rows 3–4: JSON.parse gives String keys; symbolize_names: true gives Symbol keys, all the way down.
  • Row 5: transform_keys with to_sym or to_s converts a hash's keys in one call (one level only).
  • Row 6 is the trap: {"a": 1} inspects as {a: 1}, and its key is a Symbol.
  • Row 7: a hash can hold "a" and :a side by side, as two entries. Row 8: two hashes that differ only in key class are not ==.
  • Row 9: to_json wrote {"a":1}, and parsing it back gave a String key — the round trip does not preserve symbols.
  • Row 10: conversion both ways, and a reason to prefer symbols for fixed names: a symbol is always frozen, while a string literal is not.
  • Row 11: quoting a symbol lets it hold a hyphen; inspect keeps the quotes so it can be read back.
  • Row 12: fetch raises with the key in its message, :a with the colon — the fastest way to see which kind of key a lookup used.

Compared with Python

Python has one string type, so rows 1–2 are the same lookup written with two quote styles, and row 6 is not a trap: {"a": 1} has a str key, as does every JSON object json.loads returns (rows 3–4), with no option to change that. Where Ruby chooses between two key kinds, Python chooses between str and any other hashable object — an Enum member is the closest thing to a symbol (row 5), a tuple is a valid key and a list is not (row 11), and sys.intern gives the identity-sharing that Ruby's symbols have by construction (row 10). Row 7 shows a literal with the same key twice keeps only the last value, and row 8 that an Enum key and its string value are different keys — so a program that mixes Key.A and "a" has the same bug Ruby programs have with :a and "a", and row 12 is its KeyError. Row 9 is Python's version of the round-trip change: an int key becomes a str on the way through JSON, just as a Ruby symbol does.

Verified output of string_and_symbol_keys_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. {"a": 1}["a"] / ['a']                          1 / 1
 2. "a" in d / 'a' in d                            True / True
 3. json.loads('{"a": 1, "b": [1, 2]}')            {'a': 1, 'b': [1, 2]}  key type str
 4. json.loads(...) keys are always                str  (no symbolize_names to ask for)
 5. {Key(k): v for k, v in d.items()}              {<Key.A: 'a'>: 1}  key type Key
 6. {"a": 1}   (quotes, then a colon: a str key)   {'a': 1}  key type str
 7. len({"a": 1, "a": 2})                          1  {'a': 2}  (the same key twice: last wins)
 8. {"a": 1} == {"a": 1} / {Key.A: 1} == {"a": 1}  True / False
 9. json.dumps({1: 2}), then json.loads            {"1": 2}  -> {'1': 2}  key type str
10. sys.intern("a") is sys.intern("a")             True  (interning: the nearest to a symbol)
11. {("a", "b"): 1} / {["a"]: 1}                   {('a', 'b'): 1} / TypeError  (any hashable key)
12. {"a": 1}[Key.A]                                KeyError: <Key.A: 'a'>  <- the enum member, not the string

Rails adds HashWithIndifferentAccess, a Hash subclass on which h[:a] and h["a"] find the same value; it is not part of Ruby and this library does not use it. (Not machine-checked here.)

The Python library's string literals ↗ page covers what the quote styles do and do not change in Python.

Try it

  1. Write deep_symbolize(obj) that turns every string key in a nested Hash into a Symbol, descending into Arrays as well. Run it on the result of JSON.parse for {"user": {"name": "ann", "tags": ["x", "y"]}, "items": [{"id": 1}, {"id": 2}]}, print before and after, and check it equals what symbolize_names: true produces.
  2. Write fetch_either(h, key) that tries key.to_sym and key.to_s in turn and raises a KeyError naming both spellings when neither is present. Show it on {"a" => 1}, {a: 1} and {}.
Solution to 1

string_and_symbol_keys_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: symbolize every key in a nested structure, arrays included.
require "json"

def deep_symbolize(obj)
  case obj
  when Hash  then obj.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
  when Array then obj.map { |v| deep_symbolize(v) }
  else obj
  end
end

text = '{"user": {"name": "ann", "tags": ["x", "y"]}, "items": [{"id": 1}, {"id": 2}]}'
parsed = JSON.parse(text)

puts "before: #{parsed.inspect}"
puts "after:  #{deep_symbolize(parsed).inspect}"
puts "dig:    #{deep_symbolize(parsed).dig(:items, 1, :id)}"
puts "same as symbolize_names? #{deep_symbolize(parsed) == JSON.parse(text, symbolize_names: true)}"

Verified output of string_and_symbol_keys_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

before: {"user" => {"name" => "ann", "tags" => ["x", "y"]}, "items" => [{"id" => 1}, {"id" => 2}]}
after:  {user: {name: "ann", tags: ["x", "y"]}, items: [{id: 1}, {id: 2}]}
dig:    2
same as symbolize_names? true

See also