JSON #
JSON (JavaScript Object Notation) is the most popular data exchange format in the modern web world — almost every API you consume or build uses JSON. Ruby has included the json library in its standard library since version 1.9, so no additional installation is needed for basic needs. But there are many nuances that aren’t immediately obvious: how to safely convert String keys to Symbols, how to set up custom object serialization, how to handle invalid JSON without crashing, and when to switch to a faster parser like oj. This article covers all of this, from the fundamentals to the patterns used in production APIs.
Loading the JSON Library #
# Standard library — built into Ruby, no installation needed
require 'json'
# Or use the oj gem for higher performance (needs installation)
# gem install oj
require 'oj'
Parsing JSON — Strings to Ruby Objects #
JSON.parse converts a JSON string into Ruby objects. By default, JSON keys (which are always strings) are converted to Ruby Strings:
require 'json'
# Simple JSON
json_str = '{"name":"Rina","age":28,"active":true}'
data = JSON.parse(json_str)
puts data.class # => Hash
puts data["name"] # => "Rina"
puts data["age"] # => 28
puts data["active"] # => true
# JSON array
json_arr = '[1, 2, 3, "four", true, null]'
arr = JSON.parse(json_arr)
puts arr.inspect # => [1, 2, 3, "four", true, nil]
# JSON null → Ruby nil
# Nested JSON
json_complex = '{
"user": {
"id": 1,
"name": "Budi",
"address": {
"city": "Jakarta",
"postal_code": "10110"
},
"hobbies": ["reading", "coding", "hiking"]
}
}'
data = JSON.parse(json_complex)
puts data["user"]["name"] # => "Budi"
puts data["user"]["address"]["city"] # => "Jakarta"
puts data["user"]["hobbies"].first # => "reading"
symbolize_names — Keys as Symbols #
By default the keys are Strings. To get keys as Symbols (more common in idiomatic Ruby):
json_str = '{"name":"Rina","age":28,"city":"Bandung"}'
# With symbolize_names: true
data = JSON.parse(json_str, symbolize_names: true)
puts data[:name] # => "Rina" (Symbol, not String)
puts data[:age] # => 28
# Applies recursively — all nested levels
json_nested = '{"user":{"name":"Budi","address":{"city":"Jakarta"}}}'
data = JSON.parse(json_nested, symbolize_names: true)
puts data[:user][:address][:city] # => "Jakarta"
# ANTI-PATTERN: mixing String and Symbol keys
data = JSON.parse(json_str) # keys as Strings
puts data[:name] # => nil! (looking for Symbol :name, but the key is String "name")
puts data["name"] # => "Rina"
# CORRECT: be consistent — pick one and use it forever
# For frequently accessed API responses, symbolize_names is more convenient
data = JSON.parse(json_str, symbolize_names: true)
puts data[:name] # => "Rina"
JSON ↔ Ruby Type Mapping #
JSON Ruby
───────────────────────────────
object {} → Hash
array [] → Array
string "" → String
number int → Integer
number float→ Float
true → true (TrueClass)
false → false (FalseClass)
null → nil (NilClass)
Parsing Error Handling #
Invalid JSON raises an exception. Always handle this when the input comes from an external source:
# Invalid JSON raises JSON::ParserError
begin
data = JSON.parse("{this is not json}")
rescue JSON::ParserError => e
puts "Invalid JSON: #{e.message}"
end
# A more concise pattern with inline rescue
data = JSON.parse(text) rescue nil
puts data.nil? ? "Parse failed" : "Success"
# A more informative version — build a helper
def safe_parse_json(text, default: nil)
JSON.parse(text, symbolize_names: true)
rescue JSON::ParserError => e
Rails.logger.warn("JSON::ParserError: #{e.message}") if defined?(Rails)
default
end
# Usage
data = safe_parse_json(response.body, default: {})
data = safe_parse_json("[1,2,3]") # => [1, 2, 3]
data = safe_parse_json("not json") # => {} (default)
data = safe_parse_json(nil, default: []) # => [] (nil input)
Generating JSON — Ruby Objects to Strings #
JSON.generate converts Ruby objects into a JSON string:
require 'json'
# Simple hash
hash = { name: "Citra", age: 25, city: "Surabaya" }
puts JSON.generate(hash)
# => {"name":"Citra","age":25,"city":"Surabaya"}
# Array
arr = [1, "two", :three, 4.0, true, nil, [5, 6]]
puts JSON.generate(arr)
# => [1,"two","three",4.0,true,null,[5,6]]
# Nested structure
data = {
id: 1,
user: { name: "Andi", email: "[email protected]" },
products: [{ id: 10, name: "Laptop" }, { id: 11, name: "Mouse" }],
metadata: { created_at: Time.now.iso8601, version: "1.0" }
}
puts JSON.generate(data)
pretty_generate — Readable JSON #
data = {
status: "success",
data: {
user: { id: 1, name: "Rina" },
orders: [{ id: 100, total: 150_000 }]
}
}
# Compact — for API responses (smaller size)
puts JSON.generate(data)
# Pretty — for config files or debugging
puts JSON.pretty_generate(data)
# => {
# "status": "success",
# "data": {
# "user": {
# "id": 1,
# "name": "Rina"
# },
# "orders": [
# {
# "id": 100,
# "total": 150000
# }
# ]
# }
# }
# Save to a human-readable config file
File.write("config.json", JSON.pretty_generate(data))
Shortcuts — .to_json on Objects #
Ruby adds the to_json method to all basic data types:
{ name: "Rina" }.to_json # => '{"name":"Rina"}'
[1, 2, 3].to_json # => '[1,2,3]'
"hello".to_json # => '"hello"'
42.to_json # => '42'
true.to_json # => 'true'
nil.to_json # => 'null'
Time.now.to_json # => '"2024-08-15 14:30:00 +0700"'
Custom Object Serialization #
By default, custom objects can’t be directly serialized to JSON. There are several ways to handle this:
Method 1: Implementing to_json
#
class Product
attr_reader :id, :name, :price, :active
def initialize(id, name, price, active: true)
@id = id
@name = name
@price = price
@active = active
end
def to_json(*args)
{
id: @id,
name: @name,
price: @price,
active: @active
}.to_json(*args)
end
end
product = Product.new(1, "Laptop", 15_000_000)
puts product.to_json
# => {"id":1,"name":"Laptop","price":15000000,"active":true}
# Also works in collections
product_list = [
Product.new(1, "Laptop", 15_000_000),
Product.new(2, "Mouse", 350_000, active: false)
]
puts product_list.to_json
Method 2: as_json — The Rails-Recommended Way
#
Rails/ActiveSupport provides as_json, which produces a Ruby Hash (not a String), which is then serialized by to_json. This is more flexible because it can be customized with options:
class User
attr_reader :id, :name, :email, :password_hash, :created_at
def initialize(id, name, email, password_hash)
@id = id
@name = name
@email = email
@password_hash = password_hash
@created_at = Time.now
end
def as_json(options = {})
result = {
id: @id,
name: @name,
email: @email,
created_at: @created_at.iso8601
# password_hash NOT included — sensitive data!
}
# Support :only and :except options like ActiveRecord
if options[:only]
result.slice(*Array(options[:only]))
elsif options[:except]
result.except(*Array(options[:except]))
else
result
end
end
def to_json(options = {})
as_json(options).to_json
end
end
user = User.new(1, "Rina", "[email protected]", "hash123secret")
puts user.to_json
# => {"id":1,"name":"Rina","email":"[email protected]","created_at":"..."}
# password_hash doesn't appear!
puts user.to_json(only: [:id, :name])
# => {"id":1,"name":"Rina"}
Method 3: Separate Representation (Presenter/Serializer Pattern) #
For large applications, separate the serialization logic into its own class:
class ProductSerializer
def initialize(product)
@product = product
end
def as_json
{
id: @product.id,
name: @product.name,
price: format_price(@product.price),
price_number: @product.price,
available: @product.stock > 0,
url: "/products/#{@product.id}"
}
end
def to_json
as_json.to_json
end
private
def format_price(number)
"Rp #{number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
end
end
# Usage
product = Product.find(1)
puts ProductSerializer.new(product).to_json
# For collections
product_list = Product.all.map { |p| ProductSerializer.new(p).as_json }
puts JSON.generate(product_list)
Advanced Parsing and Generation Options #
# Parsing options
JSON.parse(json_str,
symbolize_names: true, # keys as Symbols
allow_nan: true, # allow NaN and Infinity
max_nesting: 50 # maximum nesting depth (default: 19)
)
# Generation options
JSON.generate(data,
allow_nan: true, # allow NaN and Infinity in the output
max_nesting: 50 # maximum nesting depth
)
# pretty_generate with custom indentation options
puts JSON.pretty_generate(data) # default 2 spaces
# Custom indentation — JSON.state
state = JSON::State.new(
indent: " ", # 4 spaces
space: " ", # space after the colon
space_before: "", # space before the colon
object_nl: "\n", # newline after each key-value pair
array_nl: "\n" # newline after each array element
)
puts JSON.generate(data, state)
Reading and Writing JSON Files #
# Reading a JSON file
def read_json(path)
content = File.read(path)
JSON.parse(content, symbolize_names: true)
rescue Errno::ENOENT
raise "File not found: #{path}"
rescue JSON::ParserError => e
raise "Invalid JSON file: #{e.message}"
end
config = read_json("config/database.json")
puts config[:host] # => "localhost"
# Writing a JSON file
def write_json(path, data, pretty: false)
json_str = pretty ? JSON.pretty_generate(data) : JSON.generate(data)
File.write(path, json_str)
rescue IOError => e
raise "Failed to write file: #{e.message}"
end
write_json("output/result.json", { success: true, data: [1, 2, 3] })
write_json("config/settings.json", settings, pretty: true)
# Updating an existing JSON file
def update_json(path, &block)
data = read_json(path)
new_data = block.call(data)
write_json(path, new_data, pretty: true)
end
update_json("config/settings.json") do |config|
config.merge(version: "2.0", updated: Time.now.iso8601)
end
JSON Lines — One Object per Line #
JSON Lines (JSONL) is a format where each line is a separately valid JSON document. It’s very useful for streaming large data or logs:
# Writing JSON Lines
File.open("events.jsonl", "a") do |f|
{ event: "login", user_id: 1, time: Time.now.iso8601 }.tap { |e| f.puts JSON.generate(e) }
{ event: "view", user_id: 1, page: "/products", time: Time.now.iso8601 }.tap { |e| f.puts JSON.generate(e) }
end
# Reading JSON Lines — efficient for large files
def read_jsonl(path)
File.foreach(path).map { |line| JSON.parse(line.chomp, symbolize_names: true) }
end
# Or with lazy evaluation for very large files
def stream_jsonl(path)
File.foreach(path).lazy.map { |line| JSON.parse(line.chomp, symbolize_names: true) }
end
events = stream_jsonl("events.jsonl")
events.select { |e| e[:event] == "login" }.first(10).each do |e|
puts "#{e[:time]}: User #{e[:user_id]} logged in"
end
The oj Gem — The Fastest JSON Parser #
oj (Optimized JSON) is a C extension gem that’s much faster than Ruby’s built-in parser:
gem install oj
require 'oj'
# Same API as standard JSON
data = Oj.load('{"name":"Rina","age":28}') # parse
json = Oj.dump({ name: "Rina", age: 28 }) # generate
json = Oj.dump({ name: "Rina" }, indent: 2) # pretty
# Different modes for different behavior
Oj.load(json_str, mode: :strict) # strict JSON RFC
Oj.load(json_str, mode: :compat) # compatible with the JSON gem
Oj.load(json_str, mode: :object) # supports Ruby object encoding
Oj.load(json_str, mode: :rails) # compatible with Rails as_json
# Replace Ruby's default parser globally (for Rails)
# Add in config/initializers/oj.rb:
Oj.optimize_rails # replace standard JSON with oj for the whole Rails app
# Approximate benchmarks:
# JSON.parse: ~500 MB/s
# Oj.load: ~1.5 GB/s (3x faster)
When you need oj:
✓ Parsing thousands of JSON requests per second
✓ Large JSON files (> 1 MB)
✓ High-throughput applications bottlenecked at JSON parsing
✗ For regular use, the standard JSON is already more than enough
Validation with JSON Schema #
To validate the structure of incoming JSON (e.g. from API requests), use the json-schema gem:
gem install json-schema
require 'json-schema'
schema = {
"type" => "object",
"required" => ["name", "email", "age"],
"properties" => {
"name" => {
"type" => "string",
"minLength" => 2,
"maxLength" => 100
},
"email" => {
"type" => "string",
"format" => "email"
},
"age" => {
"type" => "integer",
"minimum" => 0,
"maximum" => 150
},
"role" => {
"type" => "string",
"enum" => ["user", "admin", "moderator"]
}
},
"additionalProperties" => false # reject keys not in the schema
}
# Validate data
valid_data = { "name" => "Rina", "email" => "[email protected]", "age" => 28 }
invalid_data = { "name" => "R", "email" => "not-an-email", "age" => -1 }
# Check valid/invalid
puts JSON::Validator.validate(schema, valid_data) # => true
puts JSON::Validator.validate(schema, invalid_data) # => false
# Get all errors
error_list = JSON::Validator.fully_validate(schema, invalid_data)
error_list.each { |e| puts "- #{e}" }
# => - The property '#/name' was shorter than the minimum length of 2
# => - The property '#/email' did not match the 'email' format
# => - The property '#/age' did not have a minimum value of 0
# Raise an exception if invalid
begin
JSON::Validator.validate!(schema, invalid_data)
rescue JSON::Schema::ValidationError => e
puts "Validation failed: #{e.message}"
end
JSON Patterns in Rails APIs #
# A controller returning JSON
class Api::V1::ProductsController < ApplicationController
def index
products = Product.active.limit(params[:per_page] || 20)
render json: {
status: "success",
data: products.map { |p| serialize_product(p) },
meta: {
total: Product.active.count,
page: params[:page] || 1,
per_page: params[:per_page] || 20
}
}
end
def show
product = Product.find(params[:id])
render json: { status: "success", data: serialize_product(product) }
rescue ActiveRecord::RecordNotFound
render json: { status: "error", message: "Product not found" }, status: :not_found
end
def create
data = JSON.parse(request.body.read, symbolize_names: true)
product = Product.new(data.slice(:name, :price, :category_id))
if product.save
render json: { status: "success", data: serialize_product(product) }, status: :created
else
render json: { status: "error", errors: product.errors }, status: :unprocessable_entity
end
rescue JSON::ParserError
render json: { status: "error", message: "Request body is not valid JSON" }, status: :bad_request
end
private
def serialize_product(product)
{
id: product.id,
name: product.name,
price: product.price,
category: product.category&.name,
available: product.stock > 0,
created_at: product.created_at.iso8601
}
end
end
# Middleware to automatically parse JSON bodies
# (already in Rails, but useful for Sinatra/pure Rack)
class JsonBodyParser
def initialize(app)
@app = app
end
def call(env)
if env["CONTENT_TYPE"]&.include?("application/json")
body = env["rack.input"].read
env["rack.input"].rewind
begin
env["parsed_body"] = JSON.parse(body, symbolize_names: true)
rescue JSON::ParserError
return [400, { "Content-Type" => "application/json" },
['{"error":"Invalid JSON"}']]
end
end
@app.call(env)
end
end
JSON API Format and Conventions #
# Consistent JSON response structure
# Success format
{
"status": "success",
"data": { ... }, # single object
"meta": { # optional — pagination, etc.
"total": 100,
"page": 1
}
}
# Error format
{
"status": "error",
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"errors": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "age", "message": "Must be greater than 0" }
]
}
# Helper for consistent responses
module ApiResponse
def success(data, status: :ok, meta: nil)
payload = { status: "success", data: data }
payload[:meta] = meta if meta
render json: payload, status: status
end
def error(message, code: "ERROR", status: :bad_request, errors: nil)
payload = { status: "error", code: code, message: message }
payload[:errors] = errors if errors
render json: payload, status: status
end
end
class ApplicationController < ActionController::API
include ApiResponse
end
Summary #
require 'json'is built into Ruby — no gem installation needed for basic JSON parsing and generation.symbolize_names: truefor API responses — accessingdata[:name]is more idiomatic thandata["name"]in Ruby; use it consistently.- Always handle
JSON::ParserError— input from users or external APIs can be invalid JSON; don’t let it crash.JSON.pretty_generatefor config files — more human-readable; useJSON.generate(without pretty) for API responses that prioritize small size.- Implement
as_jsonrather thanto_json—as_jsonreturns a Hash that can be combined and customized;to_jsononly turns it into a String at the end.- Don’t include sensitive data in serialization —
password_hash,api_key, and similar must not enter JSON responses; define allowed attributes explicitly inas_json.- JSON Lines for large data — one object per line enables streaming and line-by-line processing without loading the whole file into memory.
- The
ojgem for high throughput — 3x faster than the built-in parser; use it if JSON parsing becomes a bottleneck.- JSON Schema for input validation — the
json-schemagem validates structure and types before data is processed; prevents bugs caused by malformed data.- Consistent response format — define one structure
{status, data, meta}and{status, code, message, errors}for the entire API; makes it easy for clients to handle responses generically.