Skip to content
datahacks.dev
JSON

Reading JSON in Ruby With the json Gem and Its Options

JSON.parse in Ruby 3.4 with json 2.9.1: symbolized keys, big numbers, NaN, comments that parse by default, max_nesting 100, create_additions and errors.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

Ruby ships JSON in the standard library, and JSON.parse is the call nearly every Ruby program uses. It has options that matter: how keys come out, how numbers are read, whether nonstandard input is accepted and how deep a document may go. Most of the problems people have with it come from options they did not know existed.

I ran the order sample and a set of edge cases through Ruby 3.4, and report what it did.

Hashes with string keys

Ruby
require 'json'
h = JSON.parse(File.read('order.json'))
h['id']                       # 9007199254740993  (Integer)
h['customer']['email']        # nil
h['items'][1]['price']        # 12  (Integer)
h['extra']                    # {"gift" => true, "wrap" => "blue"}

The 16 digit id came back as an exact Integer, since Ruby integers have no size limit. JSON null became nil. The whole-number price 12 stayed an Integer, not 12.0, so arithmetic that mixes it with floats works but a type check for Float does not. A value written as 12.0 would have been a Float; the format does not promise one or the other, and the parser follows the text.

Keys are strings, so h[:customer] is nil. The option most people want is symbolize_names:

Ruby
s = JSON.parse(json, symbolize_names: true)
s[:customer][:name]           # "Ana"
JSON.parse('{"a":1}', symbolize_names: true).key?('a')    # false

All keys became symbols, nested ones too, and a lookup with the string form now fails. Pick one style for a codebase. Symbols are common in Ruby code and they save memory for repeated keys, but a key chosen by an attacker should not be converted to a symbol in old Rubies that never garbage collected them (modern Rubies do collect them, so this is less of a concern than it used to be).

Strict about syntax, with one exception

Errors were JSON::ParserError with the offending text:

Text
JSON.parse('{"a":1,}')          unexpected token at '{"a":1,}'
JSON.parse('')                  unexpected token at ''
JSON.parse('{"a":NaN}')         unexpected token at 'NaN}'
JSON.parse('2')                 2

A trailing comma, an empty string and NaN are rejected, and the message includes the text starting at the failure, which helps. NaN is accepted with allow_nan: true, which returned {"a" => NaN}. A bare scalar like 2 parses to 2: json 2.x follows RFC 8259, which allows any value at the top level.

The exception is comments. This version accepted a comment with no option at all:

Ruby
JSON.parse("{\"a\":1 /* c */}")      # {"a" => 1}

The result was {"a" => 1}. JSON has no comments, and a parser that accepts them lets two systems disagree about the same text: a validator in another language rejects the body that your Ruby service reads without complaint. If you accept untrusted input, validate with a stricter parser first or check the version's changelog; behavior here changed between releases, so I would not rely on it either way.

The same parser kept the last of two duplicate keys: {"role":"user","role":"admin"} became {"role" => "admin"} (JSON security mistakes in apps and pipelines lists what ten other parsers did).

Numbers

Ruby
JSON.parse('12345678901234567890')     # 12345678901234567890
JSON.parse('1e400')                    # Infinity

A big integer is exact. A float too large for a double became Infinity with no error, which can become a problem downstream when the value is compared or written back (JSON.generate(Float::INFINITY) raises). For decimals that must stay exact, ask for BigDecimal:

Ruby
require 'bigdecimal'
JSON.parse('{"p":0.1000000000000000055511151231257827}', decimal_class: BigDecimal)['p'].to_s
# "0.1000000000000000055511151231257827e0"

Without the option the value is a Float and the trailing digits are rounded away. Use decimal_class for money and measurements where digits are data.

Depth and additions

max_nesting defaults to 100:

Ruby
JSON.parse('[' * 100 + ']' * 100)                      # Array
JSON.parse('[' * 101 + ']' * 101)                      # JSON::NestingError: nesting of 101 is too deep
JSON.parse('[' * 500 + ']' * 500, max_nesting: false)  # Array

100 levels parsed, 101 raised NestingError, and max_nesting: false removed the limit. Keep the default for anything from outside (denial of service through deeply nested JSON). It is lower than most other languages' defaults, and a good one.

One option has a bad history: create_additions. When on, a member named json_class makes the parser instantiate that class, which was a source of remote code execution bugs in the past. In json 2.9.1 the default does not do it:

Ruby
JSON.parse('{"json_class":"Range","a":[1,2,false]}')
# {"json_class" => "Range", "a" => [1, 2, false]}

It stayed a plain hash. Do not turn create_additions on for untrusted input, and use JSON.parse (not JSON.load, which accepted more freedoms in older versions) for anything that arrives over a wire.

Generating

Ruby
{a: 1.0, b: nil, c: [1, "x"], t: Time.utc(2026,1,2,3,4,5)}.to_json
# {"a":1.0,"b":null,"c":[1,"x"],"t":"2026-01-02 03:04:05 UTC"}

Floats keep .0, nil is null and a Time becomes the string from to_s, not ISO 8601. Rails adds as_json and writes ISO 8601; plain Ruby does not. Call time.iso8601 (after require 'time') when the consumer expects the standard form. Failures raise JSON::GeneratorError: JSON.generate(Float::NAN) gave NaN not allowed in JSON, and a string with malformed UTF-8 gave source sequence is illegal/malformed utf-8. JSON.pretty_generate indents with two spaces.

Ruby's tools for large files include JSON.load_file for a whole file, and a streaming gem such as yajl-ruby or oj for big ones, which I did not test here. Oj is also a popular faster replacement. The reference is the json library documentation and the JSON class in the Ruby standard library. For a structure to hold typed data, Struct or Data.define plus a small mapper does the job; there is no built-in typed binding. To check a sample before you code against it, use the JSON formatter, and for validating the shape see validating request JSON before it reaches the database.

Reading from files, and handling absence

A few smaller calls came out of the same test run. JSON.load_file('/tmp/x.json') read a file straight to a Hash, and it accepts the same options as parse, so symbolize_names: true gave {a: 1}. JSON.parse(nil) raised TypeError: no implicit conversion of nil into String, which is what an empty request body usually turns into, so guard for nil before parsing. The text null parsed to nil, which is indistinguishable from a failed lookup later, so check the type of what you got.

For reaching into the result, dig is the safe tool for nested reads: dig('a','b',1) returned 20 and would have returned nil rather than raised on a missing level. When absence is an error, use fetch, which raised KeyError: key not found: "b" and says which key. Passing object_class: OpenStruct let me read a.b with method calls, at the cost of an object per hash, and freeze: true returned frozen strings, which helps when the parsed data is shared. When the output goes inside an HTML page, script_safe: true turned </script> into <\/script>.

When JSON.parse defaults are enough

Your own data. Config files, fixtures and test data written by you are fine with JSON.parse(File.read(path)). Add symbolize_names: true if you like symbols.

A framework does the parsing. Rails and Rack parse request bodies with their own limits. Read their documentation for the nesting and size they enforce before you add your own.

Small API responses. A well-behaved API returning a few kilobytes needs nothing special. For paged APIs, parse each page as it arrives.

Untrusted input. Here the defaults are close but not enough: cap the size, keep max_nesting, reject comments if you can, check for duplicate keys on decisions that matter, and validate the shape afterwards.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.