skip to content

JSON, CSV & YAML

Reading and writing JSON, CSV and YAML from Ruby: JSON.parse with symbolize_names, CSV with headers, and YAML.load vs unsafe_load. Interviewers probe which loaders are safe for untrusted input.

on this pageshow

explore

questions

6

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?

level: juniorimportance: must knowfreq 68%

answer

  1. require "json" first
  2. objects become Hash, arrays Array
  3. keys are Strings by default
  4. symbolize_names: true
  5. JSON::ParserError on bad input

basics

~20 s

JSON.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 s

After `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 lines
ruby
require "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::ParserError

go deeper

for a junior

Recall the mapping to Hash, Array and nil, that keys are Strings by default, and that symbolize_names: true makes them Symbols.

for a middle

Explain why a Symbol lookup on a String-keyed hash is silently nil, and use fetch plus JSON::ParserError handling at the boundary.

for a senior

Decide how external JSON enters the domain: key style, number precision with decimal_class, validation with fetch, and where parse errors are reported.

for a principal

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
open as a page

In Ruby 4.0, how do YAML.load, YAML.safe_load and YAML.unsafe_load differ, and why does loading a config containing a date raise Psych::DisallowedClass?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Since Psych 4 (Ruby 3.1), YAML.load is safe_load that also permits Symbol: only plain scalars, arrays and hashes load, and aliases are off. A date value would build a Date, which is not permitted, so pass permitted_classes: [Date]. unsafe_load builds any tagged class.

open as a page

In Ruby 4.0, what does CSV.foreach(path, headers: true) yield for each line, and what do the converters and header_converters options change?

level: middleimportance: should knowfreq 45%

basics

~20 s

With headers: true, CSV.foreach yields one CSV::Row per data line, indexed by header name, with every field a String. converters: :numeric turns numeric-looking fields into Integer or Float; header_converters: :symbol turns headers into snake_case Symbols.

open as a page

In Ruby, how do JSON.generate, JSON.pretty_generate and to_json differ, and what happens when you serialize an object of your own class?

level: middleimportance: should knowfreq 42%

basics

~10 s

JSON.generate returns compact JSON, JSON.pretty_generate the same with two-space indentation, and to_json is the per-object hook the generator calls. An object without its own to_json is silently written as its to_s string.

open as a page

In Ruby 4.0, whose default json gem is 2.18, how does JSON.load differ from JSON.parse, and which belongs on untrusted input?

level: middleimportance: should knowfreq 32%

basics

~20 s

JSON.load is a lenient loader for trusted data: it reads IOs, maps empty input to nil, accepts NaN and, in json 2.18, builds any class a json_class key names that defines json_create. Untrusted input belongs in JSON.parse.

open as a page

In Ruby, what can Marshal.dump not serialize, what must exist before Marshal.load can rebuild an object, and when is Marshal the wrong choice?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Marshal.dump refuses procs, methods, IO objects, anonymous classes, objects with singleton methods and hashes with a default proc, raising TypeError. Marshal.load needs every class defined, or raises ArgumentError. It is wrong for untrusted bytes and for data read by other programs.

open as a page