Skip to Content
Course content

72: JSON Module In Depth

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of time reviewing PRs where the developer just called JSON.parse(response_body) and then spent the next three hours wondering why their hash lookups were returning nil. It's a rite of passage in Ruby. You expect your keys to be symbols because that's how we write Ruby hashes, but JSON only knows strings. If you aren't careful, you end up with a codebase littered with hash["user_id"] in some places and hash[:user_id] in others, which is a recipe for a midnight production bug.

The friction of string keys

The naive way to handle a JSON response is to just parse it and hope for the best. Let's say we're pulling a user profile from an API. If you do this:

require 'json'

json_data = '{"id": 101, "username": "jdoe", "email": "jane@example.com"}'
user = JSON.parse(json_data)

puts user[:username] # => nil

You'll get nil. Why? Because JSON.parse returns a hash with string keys by default. You could fix this by changing your access to user["username"], but that feels clunky in a Ruby project. The better way is to tell the parser to symbolize the names right at the gate.

user = JSON.parse(json_data, symbolize_names: true)
puts user[:username] # => "jdoe"

I always recommend symbolize_names: true for internal API consumption. It keeps your data structures consistent with the rest of your Ruby logic. The only trade-off is memory; in very old versions of Ruby, symbols weren't garbage collected, so symbolizing keys from a massive, untrusted JSON file could lead to a memory leak. In modern Ruby, that's mostly a non-issue, but it's still a good reason to be mindful of the size of the payload you're symbolizing.

The security trap of .load

You'll see JSON.load in some older tutorials, and it looks like a drop-in replacement for JSON.parse. It isn't. I cannot stress this enough: never use JSON.load on data coming from an external source. Here is the reason why.

JSON.parse is designed to take a string and turn it into basic Ruby types: arrays, hashes, strings, and numbers. JSON.load, however, is more powerful—and dangerous. It can instantiate arbitrary Ruby objects if the JSON is formatted specifically to do so. If an attacker sends you a specially crafted JSON string, JSON.load could potentially trigger the execution of code or create objects that crash your system. Stick to JSON.parse for anything that touches the network.

Serialization and the custom object problem

When it's time to send data back out, the naive approach is to just throw your objects into a hash and call JSON.generate. But Ruby objects don't magically know how to represent themselves as JSON.

class User
  attr_accessor :name, :email
  def initialize(name, email)
    @name, @email = name, email
  end
end

me = User.new("Alice", "alice@ruby.org")
puts JSON.generate({ user: me }) # => {"user":"#" }

That's useless. You've just serialized the object's memory address. To do this properly, you should implement a to_json method or, more commonly, a method that returns a hash of the data you actually want to expose. The JSON module looks for a to_json method on objects. If you define it, you control exactly what the outside world sees.

class User
  def as_json(options = {})
    {
      name: @name,
      email: @email
    }
  end

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

puts JSON.generate({ user: me }) # => {"user":{"name":"Alice","email":"alice@ruby.org"}}

I prefer separating as_json from to_json. as_json creates the "blueprint" (the hash), and to_json handles the actual string conversion. This makes it much easier to nest your objects inside larger structures without the serialization breaking.




📋 Practical Task

Build a Secure User-Settings Synchronizer

Create a Ruby script that simulates synchronizing user settings between a local object and a JSON string received from a remote server. Your task is to implement the following:

  • Create a UserSettings class that holds theme, notifications_enabled, and font_size.
  • Implement a to_json method in the UserSettings class so it can be serialized correctly.
  • Write a sync_from_json(json_string) method that takes a JSON string and returns a new UserSettings object. Requirement: You must use JSON.parse with symbolize_names: true to ensure the keys are symbols.
  • Demonstrate a "failed" sync by attempting to pass a malformed JSON string to your method and rescuing the JSON::ParserError to print a friendly warning message instead of crashing.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.