JSON #

JSON is a data exchange format that’s nearly impossible to avoid in modern programming — APIs, configuration, structured logs, and inter-service communication almost all use JSON. Ruby provides the JSON library as part of its standard library, with no additional gem installation needed. The library provides two fundamental operations: parsing JSON strings into Ruby objects, and generating JSON strings from Ruby objects. But behind this simplicity, there are important nuances about how Ruby and JSON map data types to each other, how to handle errors properly, and how to integrate JSON with custom classes in your application.

Parsing: JSON to Ruby #

JSON.parse turns JSON strings into Ruby objects. This is the most frequent operation when working with API responses or reading configuration files.

require "json"

# Basic parsing
json_string = '{"name": "Alice", "age": 30, "active": true}'
data = JSON.parse(json_string)
# => {"name"=>"Alice", "age"=>30, "active"=>true}

data["name"]    # => "Alice"
data["age"]     # => 30
data["active"]  # => true

# JSON arrays
json_array = '[1, 2, 3, "four", null, true]'
JSON.parse(json_array)
# => [1, 2, 3, "four", nil, true]

# Nested JSON
json_nested = '{
  "user": {
    "id": 1,
    "name": "Bob",
    "address": {
      "city": "Jakarta",
      "postal_code": "10110"
    },
    "tags": ["ruby", "developer"]
  }
}'

data = JSON.parse(json_nested)
data["user"]["address"]["city"]   # => "Jakarta"
data["user"]["tags"]              # => ["ruby", "developer"]

JSON ↔ Ruby Type Mapping #

# Automatic conversion table while parsing
json = '{
  "string": "hello",
  "integer": 42,
  "float": 3.14,
  "boolean_true": true,
  "boolean_false": false,
  "null_value": null,
  "array": [1, 2, 3],
  "object": {"key": "value"}
}'

data = JSON.parse(json)

data["string"].class         # => String
data["integer"].class        # => Integer
data["float"].class          # => Float
data["boolean_true"].class   # => TrueClass
data["boolean_false"].class  # => FalseClass
data["null_value"].class     # => NilClass
data["array"].class          # => Array
data["object"].class         # => Hash

symbolize_names — String Keys vs Symbol Keys #

By default, JSON.parse uses strings as Hash keys. The symbolize_names: true option turns them into symbols.

json = '{"name": "Alice", "age": 30}'

# Default: keys are Strings
data_string = JSON.parse(json)
data_string["name"]    # => "Alice"
data_string[:name]     # => nil  (symbol doesn't match!)

# With symbolize_names: true — keys are Symbols
data_symbol = JSON.parse(json, symbolize_names: true)
data_symbol[:name]     # => "Alice"
data_symbol["name"]    # => nil  (string doesn't match!)

# Applies recursively to nested hashes
json_nested = '{"user": {"name": "Bob", "role": "admin"}}'
data = JSON.parse(json_nested, symbolize_names: true)
data[:user][:name]   # => "Bob"
data[:user][:role]   # => "admin"
# When to use symbolize_names?
# ✓ When working with internal data you control
# ✓ When keys are accessed repeatedly (symbols save memory)
# ✗ When keys come from unpredictable external input
# ✗ When keys contain characters invalid for symbols (rare but possible)

Generating: Ruby to JSON #

JSON.generate (or to_json) turns Ruby objects into JSON strings.

require "json"

# Hash to JSON
data = { name: "Alice", age: 30, active: true }
JSON.generate(data)
# => '{"name":"Alice","age":30,"active":true}'

# Or use to_json
data.to_json
# => '{"name":"Alice","age":30,"active":true}'

# Array to JSON
[1, 2, 3, "four", nil, false].to_json
# => '[1,2,3,"four",null,false]'

# Nested structures
{
  user: {
    id: 1,
    name: "Bob",
    tags: ["ruby", "developer"],
    address: nil
  }
}.to_json
# => '{"user":{"id":1,"name":"Bob","tags":["ruby","developer"],"address":null}}'

JSON.pretty_generate — Readable Output #

data = {
  status: "success",
  data: {
    users: [
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" }
    ]
  }
}

# generate: single line, compact
JSON.generate(data)
# => '{"status":"success","data":{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}}'

# pretty_generate: multi-line, 2-space indentation
puts JSON.pretty_generate(data)
# {
#   "status": "success",
#   "data": {
#     "users": [
#       {
#         "id": 1,
#         "name": "Alice"
#       },
#       {
#         "id": 2,
#         "name": "Bob"
#       }
#     ]
#   }
# }

# Use pretty_generate for config files or human-readable output
# Use generate for API responses, data transfer (smaller size)

Ruby → JSON Type Mapping #

# Directly supported types
{ key: "value" }.to_json      # => '{"key":"value"}'   Hash
[1, 2, 3].to_json             # => '[1,2,3]'            Array
"string".to_json              # => '"string"'           String
42.to_json                    # => '42'                 Integer
3.14.to_json                  # => '3.14'               Float
true.to_json                  # => 'true'               TrueClass
false.to_json                 # => 'false'              FalseClass
nil.to_json                   # => 'null'               NilClass

# Types needing special attention
:symbol.to_json               # => '"symbol"'   Symbol becomes a String!
Time.now.to_json              # => '"2024-01-15 10:30:00 +0700"'  Time becomes a String
Date.today.to_json            # => '"2024-01-15"'  Date becomes a String

# Large integers are safe in Ruby, but mind other JSON parser compatibility
9_007_199_254_740_993.to_json   # Exceeds Number.MAX_SAFE_INTEGER in JavaScript!

Ruby Symbols are converted to Strings when serialized to JSON. When you parse that JSON back, the result is a String, not a Symbol — unless you use symbolize_names: true. This can cause confusing mismatches if you’re not aware of the conversion.

data = { status: :active, id: 1 }
json = data.to_json      # => '{"status":"active","id":1}'

parsed = JSON.parse(json)
parsed["status"]         # => "active"  (String, not Symbol!)
parsed["status"] == :active  # => false!

# If you need Symbols to stay consistent, always canonicalize after parsing
parsed = JSON.parse(json, symbolize_names: true)
parsed[:status].to_sym  # => :active

Error Handling #

JSON coming from external sources isn’t always valid. Always handle parsing errors properly.

require "json"

# JSON.parse raises JSON::ParserError for invalid input
begin
  JSON.parse("this is not json")
rescue JSON::ParserError => e
  puts "Invalid JSON: #{e.message}"
end

begin
  JSON.parse('{"key": "value"')   # incomplete JSON
rescue JSON::ParserError => e
  puts "Invalid JSON: #{e.message}"
end

# Empty string
begin
  JSON.parse("")
rescue JSON::ParserError => e
  puts "Empty JSON"
end

# Safe pattern: parse with a default on failure
def safe_parse_json(string, default: nil)
  JSON.parse(string)
rescue JSON::ParserError
  default
end

result = safe_parse_json('{"valid": true}')   # => {"valid"=>true}
result = safe_parse_json("not valid")         # => nil
result = safe_parse_json("", default: {})     # => {}

# Pattern with logging
def parse_json_with_log(string, context: "unknown")
  JSON.parse(string)
rescue JSON::ParserError => e
  logger.error("JSON parse error at #{context}: #{e.message}")
  logger.debug("Input: #{string.truncate(200)}")
  nil
end
# Validating JSON without full parsing
def valid_json?(string)
  JSON.parse(string)
  true
rescue JSON::ParserError
  false
end

valid_json?('{"key": "value"}')   # => true
valid_json?("not valid")          # => false
valid_json?("null")               # => true  (null is valid JSON!)
valid_json?("42")                 # => true  (a number is valid JSON!)

Custom Class Serialization #

When you have your own class and want to convert it to/from JSON, there are several approaches to choose from.

Approach 1: to_json #

require "json"

class Product
  attr_reader :id, :name, :price, :stock

  def initialize(id, name, price, stock = 0)
    @id = id
    @name = name
    @price = price
    @stock = stock
  end

  # Convert to a Hash first, then Hash.to_json
  def as_json
    {
      id: @id,
      name: @name,
      price: @price,
      stock: @stock
    }
  end

  def to_json(*args)
    as_json.to_json(*args)
  end

  # Factory method for parsing from JSON
  def self.from_json(json_string)
    data = JSON.parse(json_string, symbolize_names: true)
    new(data[:id], data[:name], data[:price], data[:stock])
  end

  def self.from_hash(hash)
    new(hash[:id] || hash["id"],
        hash[:name] || hash["name"],
        hash[:price] || hash["price"],
        hash[:stock] || hash["stock"] || 0)
  end
end

product = Product.new(1, "Ruby Laptop", 15_000_000, 5)
json = product.to_json
# => '{"id":1,"name":"Ruby Laptop","price":15000000,"stock":5}'

# Parse back into an object
product_restored = Product.from_json(json)
product_restored.name   # => "Ruby Laptop"

Approach 2: JSON.load with a custom parser #

require "json"

class User
  attr_accessor :id, :name, :email, :created_at

  def initialize(attrs = {})
    @id = attrs["id"] || attrs[:id]
    @name = attrs["name"] || attrs[:name]
    @email = attrs["email"] || attrs[:email]
    @created_at = attrs["created_at"] || attrs[:created_at]
  end

  def to_json(*args)
    {
      "id" => @id,
      "name" => @name,
      "email" => @email,
      "created_at" => @created_at&.iso8601
    }.to_json(*args)
  end
end

# With JSON.load and a custom create_id
class UserSerializer
  def self.dump(user)
    JSON.generate({
      id: user.id,
      name: user.name,
      email: user.email
    })
  end

  def self.load(json_string)
    data = JSON.parse(json_string)
    User.new(data)
  end
end

Working with JSON Files #

A common scenario: reading configuration from a JSON file or saving data to a JSON file.

require "json"
require "pathname"

# Reading from a file
def read_config(path)
  file = Pathname.new(path)
  raise "File not found: #{path}" unless file.exist?

  JSON.parse(file.read, symbolize_names: true)
rescue JSON::ParserError => e
  raise "Invalid config file: #{e.message}"
end

config = read_config("config/settings.json")
config[:database][:host]   # => "localhost"

# Writing to a file
def save_data(data, path)
  file = Pathname.new(path)
  file.dirname.mkpath   # make sure the directory exists

  file.open("w") do |f|
    f.write(JSON.pretty_generate(data))
  end
end

save_data({ version: "1.0", timestamp: Time.now.iso8601 }, "output/result.json")

# Pattern: read-modify-write with atomic write
def update_config(path, &block)
  file = Pathname.new(path)
  data = JSON.parse(file.read, symbolize_names: true)

  block.call(data)

  tmp = Pathname.new("#{path}.tmp.#{Process.pid}")
  tmp.write(JSON.pretty_generate(data))
  tmp.rename(file)
end

update_config("config/settings.json") do |config|
  config[:version] = "2.0"
  config[:updated_at] = Time.now.iso8601
end

Advanced Options #

require "json"

# max_nesting — limit the parsing depth (default: 100)
# Prevents stack overflows from deeply nested JSON
begin
  deeply_nested = "[" * 200 + "]" * 200
  JSON.parse(deeply_nested, max_nesting: 10)
rescue JSON::NestingError => e
  puts "Too deep: #{e.message}"
end

# allow_nan — allow NaN and Infinity (not standard JSON)
JSON.generate(Float::NAN)   # => JSON::GeneratorError!

JSON.generate(Float::NAN, allow_nan: true)   # => 'NaN'
JSON.parse("NaN", allow_nan: true)           # => Float::NAN

# Serialization with a custom state
json = JSON.generate(data,
  indent: "  ",          # 2 spaces for indentation
  space: " ",            # space after : and ,
  object_nl: "\n",       # newline after each key-value pair
  array_nl: "\n"         # newline after each array element
)

# JSON.dump vs JSON.generate
# JSON.dump: more permissive, uses to_json when available
# JSON.generate: stricter, needs known types

Real-World Usage Patterns #

Consistent API Responses #

require "json"

class ApiResponse
  def self.success(data, message: "OK")
    {
      status: "success",
      message: message,
      data: data,
      timestamp: Time.now.utc.iso8601
    }.to_json
  end

  def self.error(message, code: 400, detail: nil)
    response = {
      status: "error",
      message: message,
      code: code,
      timestamp: Time.now.utc.iso8601
    }
    response[:detail] = detail if detail
    response.to_json
  end
end

# Usage
ApiResponse.success({ user: { id: 1, name: "Alice" } })
# => '{"status":"success","message":"OK","data":{"user":{"id":1,"name":"Alice"}},...}'

ApiResponse.error("User not found", code: 404)
# => '{"status":"error","message":"User not found","code":404,...}'

JSON Cache with Validation #

require "json"

class JsonCache
  def initialize(path)
    @path = Pathname.new(path)
    @data = load_from_disk
  end

  def [](key)
    @data[key.to_s]
  end

  def []=(key, value)
    @data[key.to_s] = value
    save_to_disk
  end

  def delete(key)
    @data.delete(key.to_s)
    save_to_disk
  end

  private

  def load_from_disk
    return {} unless @path.exist?

    JSON.parse(@path.read)
  rescue JSON::ParserError
    {}  # Reset if the file is corrupt
  end

  def save_to_disk
    @path.dirname.mkpath
    @path.write(JSON.pretty_generate(@data))
  end
end

cache = JsonCache.new("tmp/cache.json")
cache["token"] = "abc123"
cache["expires"] = Time.now.to_i + 3600
puts cache["token"]   # => "abc123"

Summary #

  • require "json" first — unlike Array and Hash, JSON must be explicitly required before use.
  • JSON.parse for parsing, to_json for generating — the two most fundamental operations; use JSON.pretty_generate for human-readable output.
  • symbolize_names: true for internal APIs — converts keys from String to Symbol when parsing; useful for symbol-notation access but avoid it for keys from unpredictable external input.
  • Always rescue JSON::ParserError — JSON from external sources isn’t always valid; handle this error explicitly rather than letting the app crash.
  • Ruby Symbols become Strings in JSON — when serializing to JSON, Symbols are automatically converted to Strings; when parsing back, the result is a String, not a Symbol.
  • Implement as_json and to_json for custom classesas_json returns the Hash representation, to_json delegates to as_json; this is the pattern followed by Rails and many libraries.
  • Use atomic writes for JSON files — write to a temporary file first, then rename to the target; this prevents file corruption if the process stops mid-write.
  • max_nesting for protection against malicious input — limit the JSON parsing depth to prevent stack overflows from intentionally deeply nested input.

← Previous: FileUtils   Next: CSV →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact