In Ruby, what does JSON.parse return for a JSON object, and why does data[:name] come back nil unless you pass symbolize_names: true?
answer
- require "json" first
- objects become Hash, arrays Array
- keys are Strings by default
- symbolize_names: true
- JSON::ParserError on bad input
basics
~20 sJSON.parse turns a JSON object into a Hash whose keys are Strings, arrays into Arrays and null into nil. data[:name] looks up a Symbol key that does not exist, so it returns nil; symbolize_names: true makes the keys Symbols.
solid answer
~40 sAfter `require "json"`, `JSON.parse(text)` maps JSON onto core classes: an object becomes a `Hash`, an array an `Array`, strings `String`, whole numbers `Integer`, numbers with a fraction or exponent `Float`, `true`/`false` themselves and `null` becomes `nil`. Object keys stay **Strings**, so `data["name"]` works and `data[:name]` quietly returns `nil`, because a `Symbol` key and a `String` key are different keys. Passing `symbolize_names: true` converts every key, at every depth, to a `Symbol`, which suits Ruby's hash shorthand and `case/in` hash patterns (they match Symbol keys only). Invalid input raises `JSON::ParserError`; `JSON.load_file(path, symbolize_names: true)` does the same for a file. To fail loudly on a missing key, use `data.fetch(:name)` instead of `[]`.
code
ruby · 13 linesrequire "json"
text = '{"sku": "LMP-01", "name": "Desk lamp", "tags": ["home"], "discontinued": null}'
JSON.parse(text)
# => {"sku" => "LMP-01", "name" => "Desk lamp", "tags" => ["home"], "discontinued" => nil}
product = JSON.parse(text, symbolize_names: true)
product[:name] # => "Desk lamp"
product => {sku:, name:}
sku # => "LMP-01"
JSON.parse("") # JSON::ParserErrorgo deeper
Recall the mapping to Hash, Array and nil, that keys are Strings by default, and that symbolize_names: true makes them Symbols.
Explain why a Symbol lookup on a String-keyed hash is silently nil, and use fetch plus JSON::ParserError handling at the boundary.
Decide how external JSON enters the domain: key style, number precision with decimal_class, validation with fetch, and where parse errors are reported.
Set one convention for key style and number handling across services, so every importer does not rediscover the String-versus-Symbol and Float-money traps.
## From JSON text to Ruby objects The `json` library ships with Ruby as a **default gem** (Ruby 4.0.7 includes json 2.18.0), so `require "json"` works without a Gemfile entry. `JSON.parse(source)` reads a JSON document from a `String` and builds ordinary Ruby objects: | JSON | Ruby | |---|---| | object `{"a": 1}` | `Hash`, with **String** keys: `{"a" => 1}` | | array `[1, 2]` | `Array` | | string `"x"` | `String` | | number `42` | `Integer` | | number `19.99` or `1e3` | `Float` | | `true`, `false` | `true`, `false` | | `null` | `nil` | Nothing else appears: no `Symbol`, no `Time`, no custom class. A timestamp is just a `String` until your code parses it. ## Why `data[:name]` is nil In Ruby a `Hash` key is an object, and `"name"` (a `String`) and `:name` (a `Symbol`) are different objects that are not `eql?` to each other. So after ```ruby data = JSON.parse('{"name": "Desk lamp", "price": 24.5}') data["name"] # => "Desk lamp" data[:name] # => nil ``` the Symbol lookup finds no such key and `Hash#[]` returns `nil`, the hash's default. The bug is silent: `nil` flows onward until something calls a method on it, usually far from the parse. ## `symbolize_names: true` The `symbolize_names` option makes the parser create **Symbol keys** instead: - it applies to **every** nested object, not just the top level; - it affects only keys; string **values** stay Strings; - it cannot be combined with the legacy `create_additions` option, which the parser rejects with `ArgumentError`. Symbol keys matter for idiomatic code beyond `[]`: 1. **Keyword-style destructuring**: `data => {name:, price:}` binds locals from Symbol keys only. 2. **Hash patterns** in `case/in` match Symbol keys, so a String-keyed hash never matches `in {name: String}`. 3. **Keyword arguments**: splatting into a method with keyword parameters, such as `def initialize(sku:, price:)`, needs Symbol keys; String keys leave the keywords missing and raise `ArgumentError`. Symbols created this way are ordinary dynamic Symbols and are garbage-collected when unused, so symbolizing keys of trusted documents is not a memory leak. ## A catalogue import, step by step Suppose a supplier exports the product catalogue as `products.json`, an array of objects: 1. Read and parse: `products = JSON.load_file("products.json", symbolize_names: true)`. `JSON.load_file` reads the file as UTF-8 and calls `JSON.parse` on it, so it has the same safe behaviour as `parse`. 2. Validate shape: `products.each { |p| p.fetch(:sku); p.fetch(:price) }`. `Hash#fetch` raises `KeyError` naming the missing key, which is far easier to debug than a `nil`. 3. Convert values: prices arrive as `Float`. If money precision matters, parse decimals as `BigDecimal` with the `decimal_class: BigDecimal` option (BigDecimal is a bundled gem since Ruby 3.4), or store cents as integers. ## Errors and other useful options - **Malformed input** raises `JSON::ParserError`, including for an empty string. Rescue it at the boundary where the text arrives and report which file or request was bad. - **`freeze: true`** returns deeply frozen objects, useful for configuration you never intend to modify. - **`max_nesting`** defaults to 100 levels for `JSON.parse`; deeper documents raise `JSON::NestingError`. - **`allow_nan`** is off, so `NaN` and `Infinity`, which are not valid JSON, raise `JSON::ParserError`. ## Mistakes that show up in review - **Mixing key styles** in one code path: parsing with String keys in one place and symbolizing in another, then comparing or merging the two hashes, silently produces duplicate entries such as `"sku"` and `:sku` side by side. - **Parsing twice**: calling `JSON.parse` on something that is already a Hash raises `TypeError`, because `parse` expects a String; decide at the boundary where text becomes data. - **Rescuing too broadly**: `rescue => e` around a parse also hides bugs in the code that walks the result. Rescue `JSON::ParserError` alone, as close to the parse as possible. - **Assuming a top-level object**: a document can be an array, a number or even `null`, so check the class of the result before treating it as a Hash. ## What to take away | Want | Write | |---|---| | String keys, the default | `JSON.parse(text)` | | Symbol keys at every depth | `JSON.parse(text, symbolize_names: true)` | | Parse a file | `JSON.load_file(path, symbolize_names: true)` | | Fail loudly on a missing key | `data.fetch(:sku)` |
- Does symbolize_names convert string values that look like identifiers?No. It changes only the keys of JSON objects, at every nesting level. A value such as `"status": "active"` stays the String `"active"`; converting values would be the application's job, for example with `to_sym` on a known, small set of statuses.
- How would you parse prices without Float rounding?Pass `decimal_class: BigDecimal` so numbers with a fraction are built as `BigDecimal` instead of `Float` (BigDecimal is a bundled gem since Ruby 3.4, so it needs a Gemfile entry under Bundler). Alternatively have the producer send integer cents, which parse exactly as `Integer`.
- What does JSON.parse raise on an empty string or truncated document?`JSON::ParserError`, whose message points at the unexpected token. Empty input is not valid JSON, so `JSON.parse("")` raises too; only `JSON.load` turns empty or nil input into `nil`, through its `allow_blank` default.
saying these in an interview costs you the question
- JSON.parse returns Symbol keys by default
- symbolize_names also converts string values to Symbols
- data[:name] and data["name"] find the same entry
- JSON.parse returns nil for malformed input
- JSON.parse turns ISO 8601 strings into Time objects