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

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: true for API responses — accessing data[:name] is more idiomatic than data["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_generate for config files — more human-readable; use JSON.generate (without pretty) for API responses that prioritize small size.
  • Implement as_json rather than to_jsonas_json returns a Hash that can be combined and customized; to_json only turns it into a String at the end.
  • Don’t include sensitive data in serializationpassword_hash, api_key, and similar must not enter JSON responses; define allowed attributes explicitly in as_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 oj gem 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-schema gem 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.

← Previous: Mocking   Next: YAML →

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