← Back to blog

JSON.parse with symbolize_names: Symbol Keys, and Everything Else That Does Not Come Back

· Lachlan Young

Every Rails developer eventually writes this test, watches it fail, and stares at two hashes that look the same:

payload = { status: "active", plan: "pro" }
parsed = JSON.parse(payload.to_json)

parsed == payload
# => false

parsed
# => {"status"=>"active", "plan"=>"pro"}

JSON.parse returns string keys. JSON object keys are always strings, and the parser does not know or care that the hash started life with symbols. The usual fix is one keyword argument away:

parsed = JSON.parse(payload.to_json, symbolize_names: true)
# => {:status=>"active", :plan=>"pro"}

parsed == payload
# => true

That is the whole answer for simple cases, and it is why most people stop reading here. The trouble is that symbolize_names fixes exactly one thing, the keys, and it is very easy to walk away believing JSON is now a faithful round trip. It is not. This post covers what the option actually does, what it can never restore, and how I handle parsed JSON in tests so these failures stop being mysterious.

What symbolize_names does

symbolize_names: true tells the parser to build every object key as a symbol instead of a string. It applies at every level of nesting, including hashes inside arrays, so you do not need a deep variant:

JSON.parse('{"user":{"roles":[{"name":"admin"}]}}', symbolize_names: true)
# => {:user=>{:roles=>[{:name=>"admin"}]}}

That alone makes it better than parsing first and fixing keys afterwards. In plain Ruby there is no built in deep_symbolize_keys, and the shallow transform_keys(&:to_sym) misses nested hashes. In Rails, JSON.parse(body).deep_symbolize_keys gets the same result, but it builds the string keyed structure first and then copies it. Letting the parser produce symbols directly is simpler and cheaper.

The option only touches keys. Values are untouched, which is where the surprises start.

What a round trip cannot give back

JSON has six kinds of value: objects, arrays, strings, numbers, booleans, and null. Ruby has many more. Anything outside those six is converted to one of them on the way out, and the parser has no way of knowing what it used to be. Here is a hash with a few ordinary types in it, run through plain Ruby’s JSON library:

require "json"
require "bigdecimal"

original = {
  status: :active,
  created_at: Time.utc(2026, 10, 6, 9, 30),
  price: BigDecimal("19.99"),
  1 => "one"
}

back = JSON.parse(original.to_json, symbolize_names: true)
# => {:status=>"active",
#     :created_at=>"2026-10-06 09:30:00 UTC",
#     :price=>"0.1999e2",
#     :"1"=>"one"}

back == original
# => false

Every one of those keys is wrong in a different way:

None of these are bugs. JSON simply has nowhere to store the information. The mistake is expecting symbolize_names to undo a conversion it was never part of.

The rule I actually follow

Treat JSON as a boundary, and compare data on the side of the boundary where it is meant to live.

If the code under test produces JSON, assert against what the JSON should contain, written in JSON’s own terms. String values, ISO 8601 strings, decimal strings:

test "serializes the order" do
  order = orders(:pending)
  body = JSON.parse(OrderSerializer.new(order).to_json, symbolize_names: true)

  assert_equal(
    {
      id: order.id,
      status: "pending",
      total: "19.99",
      placed_at: order.placed_at.utc.iso8601(3)
    },
    body
  )
end

The expected hash uses symbol keys because I parsed with symbolize_names: true, and plain strings for everything JSON turned into a string. It describes the wire format honestly. When a serializer accidentally switches total from a string to a float, this test catches it, which is exactly what I want, because API clients care about that change.

The tempting alternative, assert_equal order.attributes.symbolize_keys, body, compares a Ruby object graph against its JSON shadow and fails on every timestamp and decimal in the record. People then start sprinkling to_s over the expected side until it passes, and the test ends up asserting nothing useful.

If you need real Ruby objects back, do not lean on symbolize_names to get there. Parse, then convert the specific fields explicitly at the boundary:

data = JSON.parse(raw, symbolize_names: true)

Order.new(
  status: data.fetch(:status).to_sym,
  placed_at: Time.iso8601(data.fetch(:placed_at)),
  total: BigDecimal(data.fetch(:total))
)

That is more typing, but every conversion is visible, and a missing field raises from fetch instead of quietly producing nil three method calls later.

Rails tests have their own wrinkle

In request and integration tests, most people reach for response.parsed_body rather than calling JSON.parse themselves. Since Rails 7.1, parsed_body for a JSON response returns a HashWithIndifferentAccess. That makes reading values pleasant, body[:status] and body["status"] both work, but it stores string keys internally, so comparing it with == against a symbol keyed hash fails:

body = response.parsed_body
body[:status]                         # => "pending"
body == { status: "pending" }         # => false

You can compare against a string keyed expectation, call body.deep_symbolize_keys before asserting, or parse response.body yourself with symbolize_names: true. I prefer the last one in API tests because it gives a plain Hash with no extra behaviour, and the failure output prints symbols that match the expected side. The deeper reasons indifferent access trips up equality are covered in HashWithIndifferentAccess: The Rails Convenience That Breaks Your Tests.

Should you worry about symbolizing untrusted input?

This used to be a real concern. Before Ruby 2.2, symbols were never garbage collected, so symbolizing keys from arbitrary user input let an attacker grow memory without limit. Dynamically created symbols have been collectable since 2.2, so on any Ruby you are likely running today, symbolize_names: true on request bodies is fine from a memory standpoint.

What still matters is validating which keys you accept. Symbolizing does not make input trustworthy, it just changes the key type. Pass the result through slice, strong parameters, or a schema check before it goes anywhere important.

JSON.parse takes a handful of other keyword arguments that pair well with symbolize_names:

JSON.parse(raw, symbolize_names: true, freeze: true)
# Every hash, array, and string in the result is frozen.

JSON.parse('{"price":19.99}', decimal_class: BigDecimal)
# => {"price"=>0.1999e2}

freeze: true is useful for configuration loaded once at boot, where accidental mutation would be a nasty bug. decimal_class: BigDecimal parses JSON numbers with a decimal point as BigDecimal instead of Float, which is the right call when the payload contains money sent as bare numbers. It does nothing for decimals that were already encoded as strings, so it will not fix the Rails "19.99" case above.

Avoid JSON.load for anything from outside your app. It has historically enabled options like create_additions that can instantiate arbitrary classes from the payload. JSON.parse is the right default.

The short version

Pass symbolize_names: true when you want symbol keys, and let the parser do it rather than fixing keys afterwards. Remember that it only changes keys. Symbols, times, decimals, and non string keys all come back as strings or string symbols, and no option restores them. In tests, write the expected side in JSON’s terms, or convert fields explicitly after parsing. Do not compare a Ruby object graph with its JSON round trip and hope.

When one of these comparisons fails anyway, the cause is almost always a value that changed type, a BigDecimal that became a string or a symbol that became a string, hiding inside an otherwise identical hash. Try RubyHash to paste both sides of the failure and see exactly which value changed type, without scanning the output by eye.

Enjoyed this post?

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