Working with JSON

Published by Programming Line in Ruby. Last updated .

JSON (JavaScript Object Notation) is the de facto format for exchanging structured data between programs, whether you’re calling a web API, reading a configuration file, or writing test fixtures to disk. Ruby ships with a JSON library in its standard library, so turning JSON text into native Ruby objects — and back again — takes nothing more than a require 'json' line. This lesson covers parsing, generating, pretty-printing, the string-vs-symbol key gotcha, and the mistakes that trip up almost everyone the first time they touch JSON in Ruby.

Overview: How JSON Works in Ruby

JSON is a lightweight, text-based data format built around exactly six kinds of values: objects ({...}), arrays ([...]), strings, numbers, booleans (true/false), and null. Ruby’s standard library ships a JSON module that maps each of those onto a native Ruby type, so once you parse a document you’re working with ordinary hashes, arrays, strings, and numbers — nothing exotic, and every method you already know on Hash and Array (each, map, select, dig) works immediately.

require 'json' loads the module and, importantly, also reopens Ruby’s core classes (Hash, Array, String, Integer, Float, NilClass, TrueClass, FalseClass, and Symbol) to add a to_json instance method to each. That’s why, after requiring the library, you can call .to_json directly on a plain hash or array without wrapping it in anything else. Because almost everything in Ruby is an object, this is really just ordinary method mixing-in — the JSON library extends existing classes rather than inventing new syntax.

The two directions you’ll use constantly:

  • Parsing — turning a JSON string into Ruby objects with JSON.parse.
  • Generating — turning Ruby objects into a JSON string with JSON.generate, JSON.pretty_generate, or to_json.
JSON type Ruby type after JSON.parse
object {} Hash
array [] Array
string String
number Integer or Float
true / false true / false
null nil

By default, JSON.parse returns a Hash with String keys, because JSON itself has no concept of a symbol — only strings exist as a key type. If your code expects to index a parsed hash with symbols (data[:name] instead of data["name"]), pass the symbolize_names: true option. The reverse also holds: when you generate JSON from a hash that uses symbol keys or symbol values, JSON.generate converts every symbol to its string representation, because the JSON spec has no symbol type to serialize into. A symbol like :programmer always comes back out the other side as the string "programmer".

Because JSON’s null becomes Ruby’s nil, and Ruby’s truthiness treats only nil and false as falsy, a parsed value of false is just as “falsy” in an if check as a missing key that returns nil from Hash#[] — but they mean different things: an explicit false versus an absent key entirely. Don’t conflate if data["active"] with checking whether the key exists; use data.key?("active") when that distinction matters.

Syntax

JSON.parse(json_string, symbolize_names: true)  # => Hash or Array
JSON.generate(ruby_object)                       # => compact JSON String
JSON.pretty_generate(ruby_object)                # => indented JSON String
ruby_object.to_json                              # => same as JSON.generate
Method Purpose
JSON.parse(str, symbolize_names: true) Convert a JSON string into Ruby objects; keys become symbols when the option is set (default is string keys)
JSON.generate(obj) Convert a Ruby object into a compact, single-line JSON string
JSON.pretty_generate(obj) Same as generate, but indented and newline-formatted for humans
obj.to_json Instance method equivalent to JSON.generate(obj), available on core types after require 'json'
JSON::ParserError The exception JSON.parse raises when given malformed JSON

Examples

Example 1: Parsing a JSON string

require 'json'

json_string = '{"name": "Ada", "age": 36, "languages": ["Ruby", "Assembly"]}'

data = JSON.parse(json_string)
puts data.class
puts data["name"]
puts data["age"]
puts data["languages"].join(", ")

Output:

Hash
Ada
36
Ruby, Assembly

The JSON object became a Hash, the JSON array became an Array, and the JSON string and number became a Ruby String and Integer respectively. Notice the keys are plain strings ("name"), which is the default — nothing here asked for symbols.

Example 2: Generating JSON, pretty-printing, and round-tripping

require 'json'

person = { name: "Grace Hopper", role: :programmer, born: 1906 }

json_output = JSON.generate(person)
puts json_output

pretty_output = JSON.pretty_generate(person)
puts pretty_output

parsed_back = JSON.parse(json_output, symbolize_names: true)
puts parsed_back[:name]
puts parsed_back[:role]

Output:

{"name":"Grace Hopper","role":"programmer","born":1906}
{
  "name": "Grace Hopper",
  "role": "programmer",
  "born": 1906
}
Grace Hopper
programmer

The symbol :programmer is generated as the plain string "programmer", since JSON has no symbol type. When it’s parsed back with symbolize_names: true, the key role becomes the symbol :role, but its value remains the string "programmer" — symbolize_names only affects keys, never values.

Example 3: Writing and reading a JSON file

require 'json'

inventory = [
  { sku: "A100", item: "Widget", qty: 42 },
  { sku: "B200", item: "Gadget", qty: 7 }
]

File.write("inventory.json", JSON.pretty_generate(inventory))

file_contents = File.read("inventory.json")
loaded = JSON.parse(file_contents, symbolize_names: true)

total = loaded.sum { |entry| entry[:qty] }
puts "Loaded #{loaded.size} items, total quantity: #{total}"

loaded.each do |entry|
  puts "#{entry[:sku]}: #{entry[:item]} (#{entry[:qty]})"
end

Output:

Loaded 2 items, total quantity: 49
A100: Widget (42)
B200: Gadget (7)

This is the pattern you’ll use for config files, fixtures, and caches: serialize an array of hashes with pretty_generate so it’s readable in version control, write it with File.write, then read and re-parse it later with File.read plus JSON.parse. Because symbolize_names: true was used, every hash in loaded can be indexed with symbols like entry[:qty].

How It Works Step by Step

  1. JSON.parse reads the source text and recursively descends into it: when it encounters {, it starts building a Hash; when it encounters [, it starts building an Array.
  2. For each key/value pair inside an object, the parser first fully resolves the value — which may itself be a nested object or array, parsed recursively — before adding it to the hash being built.
  3. Scalars (strings, numbers, true, false, null) convert directly to their matching Ruby literal as soon as they’re read, with no further processing needed.
  4. If symbolize_names: true is passed, every object key encountered during this walk is interned into a Symbol instead of left as a String; values are untouched.
  5. JSON.generate (and to_json) works in the opposite direction: it walks the Ruby object tree recursively, emitting the matching JSON syntax for each Hash, Array, String, number, boolean, and nil it finds, converting any Symbol key or value to a JSON string along the way.
  6. If the source text is not valid JSON — an unmatched brace, single quotes instead of double quotes, a trailing comma — parsing stops immediately and JSON.parse raises JSON::ParserError rather than returning a partial result.

Common Mistakes

Mistake 1: Assuming parsed keys are symbols

JSON.parse returns string keys by default. Indexing the result with a symbol silently returns nil instead of raising an error, which makes the bug easy to miss:

require 'json'

json_string = '{"name": "Ada", "age": 36}'
data = JSON.parse(json_string)

puts "Name: #{data[:name].inspect}"

Output:

Name: nil

data has the key "name" (a String), not :name (a Symbol), so data[:name] looks up a key that was never there and quietly returns nil — no exception, just a wrong value further down the line. Fix it by requesting symbol keys at parse time:

require 'json'

json_string = '{"name": "Ada", "age": 36}'
data = JSON.parse(json_string, symbolize_names: true)

puts "Name: #{data[:name].inspect}"

Output:

Name: "Ada"

Mistake 2: Not handling malformed JSON

JSON requires double-quoted strings; single quotes, trailing commas, or unquoted keys are all invalid. Calling JSON.parse on such text without a rescue crashes the program the moment untrusted input (a file, an API response, user input) doesn’t match the spec:

require 'json'

malformed = "{'name': 'Ada'}"
data = JSON.parse(malformed)
puts data["name"]

This raises JSON::ParserError before the puts line ever runs, because {'name': 'Ada'} uses single quotes, which JSON does not allow. The fix is to rescue the specific error whenever the source of the JSON isn’t fully trusted:

require 'json'

malformed = "{'name': 'Ada'}"

begin
  data = JSON.parse(malformed)
  puts data["name"]
rescue JSON::ParserError
  puts "Could not parse JSON input"
end

Output:

Could not parse JSON input

Best Practices

  • Always require 'json' explicitly at the top of any file that uses it, even if another gem happens to load it transitively.
  • Decide up front whether your code will use string or symbol keys and apply symbolize_names: true consistently, rather than mixing both styles in the same codebase.
  • Wrap JSON.parse calls on external or untrusted input (files, HTTP responses, user-supplied text) in begin/rescue JSON::ParserError instead of letting a malformed payload crash the program.
  • Use JSON.pretty_generate for files meant to be read or diffed by humans (config files, fixtures); use the compact JSON.generate/to_json for data sent over the wire.
  • Remember that symbolize_names only affects hash keys — values remain whatever type they were (a JSON string value stays a Ruby String, never becomes a Symbol).
  • For your own classes, define a to_json method so instances serialize the way you intend, rather than relying on their default string representation.
  • Use data.key?("name") (or data.key?(:name) with symbolized keys) when you need to distinguish an explicit false/null value from a key that’s simply missing.

Practice Exercises

  • Given the JSON string '{"title": "Ruby Basics", "minutes": 45, "tags": ["beginner", "ruby"]}', parse it and print a sentence like "Ruby Basics takes 45 minutes and covers: beginner, ruby".
  • Build a Ruby array of three hashes representing books (each with :title, :author, and :year), write it to books.json using JSON.pretty_generate, then read the file back and print each title on its own line.
  • Write a method safe_parse(json_string) that returns the parsed hash with symbol keys on success, or nil if the string is not valid JSON. Test it against both a valid and an invalid string and print the result of each call.

Summary

  • require 'json' loads Ruby’s built-in JSON support and adds to_json to core classes.
  • JSON.parse converts a JSON string into Ruby Hash/Array/scalar values; keys are strings unless you pass symbolize_names: true.
  • JSON.generate, JSON.pretty_generate, and to_json convert Ruby objects back into JSON text; symbols always serialize as strings since JSON has no symbol type.
  • Malformed JSON raises JSON::ParserError — always rescue it when parsing input you don’t fully control.
  • Only nil and false are falsy in Ruby, so use key? rather than a bare truthiness check when you need to tell an explicit false/null apart from a missing key.