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.parsefor parsing,to_jsonfor generating — the two most fundamental operations; useJSON.pretty_generatefor human-readable output.symbolize_names: truefor 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_jsonandto_jsonfor custom classes —as_jsonreturns the Hash representation,to_jsondelegates toas_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_nestingfor protection against malicious input — limit the JSON parsing depth to prevent stack overflows from intentionally deeply nested input.