JSON is the format everyone agrees on. So I wrote 24 awkward but small documents, fed the identical bytes to six parsers on one laptop, and compared what came back.
Five of the 24 behaved the same way everywhere. Three parsed to the same value and two were rejected by all six. The other 19 produced divergence: either some parsers accepted a document that others refused, or they all accepted it and disagreed about what it meant.
The parsers are Python 3.13 json, Node 24 JSON.parse, Ruby 2.6 json, Perl JSON::PP, jq 1.8.1, and Swift 6.3 JSONSerialization.
The one with security consequences
{"a":1,"a":2}
RFC 8259 says the names within an object "SHOULD be unique" and that behaviour when they are not is unpredictable. It is not kidding.
Five parsers give you {"a":2}. Swift gives you {"a":1}.
Last-wins against first-wins, and it holds across every shape I tried: mixed types ({"a":"first","a":2}), nested objects, even duplicate empty keys. Swift takes the first occurrence, everyone else takes the last.
That is a real problem if two services in one system disagree. A gateway that validates {"role":"user","role":"admin"} and reads admin while the backend reads user is a bug you cannot see in either codebase, because each is individually correct. The RFC blessed both readings.
The one that costs you money
{"n":12345678901234567890}
Python, Ruby, Perl, jq and Swift all return that number exactly. Node returns:
12345678901234567000
The last three digits are gone. No error, no warning, no flag on the result. It happens at 9223372036854775808 too, which is where a signed 64-bit integer overflows, and Node hands back 9223372036854776000.
This is IEEE 754 doing exactly what it is specified to do, and it is still the single most common way a database ID gets quietly corrupted in transit. Anything past 2^53 is a lossy round trip through JavaScript.
Eight documents split the room
These are the cases where some parsers accepted the bytes and others refused them outright.
| document | accepted by | rejected by |
|---|---|---|
{/*c*/"a":1} |
ruby | python, node, perl, jq, swift |
{"a":1,} |
swift | python, node, ruby, perl, jq |
{"n":01} |
jq | python, node, ruby, perl, swift |
BOM before {
|
jq, swift | python, node, ruby, perl |
{"s":"\ud800"} |
python, node | ruby, perl, jq, swift |
{"n":NaN} |
python, jq | node, ruby, perl, swift |
{"n":Infinity} |
python, jq | node, ruby, perl, swift |
{"n":1e400} |
python, node, perl, jq | ruby, swift |
Ruby accepts C-style block comments. Swift accepts a trailing comma. jq accepts a leading zero and silently normalises 01 to 1. None of those are JSON.
The lone surrogate is the one I would watch. \ud800 is the high half of a surrogate pair with nothing after it, which is not a valid character. Python and Node hand it to you anyway; the other four refuse the document. If your pipeline is Node then Ruby, the first hop accepts a string the second hop cannot.
Infinity is where it gets silly
{"n":1e400} overflows a double. Six parsers, five different answers:
| parser | result |
|---|---|
| python | Infinity |
| node | null |
| perl | Inf |
| jq | 1E+400 |
| ruby | error on generate |
| swift | rejected |
jq keeps the literal text and hands back 1E+400, which is arguably the most honest thing in the table. Node's answer is the one that will hurt: the value becomes null, which is a perfectly ordinary JSON value that your code will happily store.
And when jq is given the literal token Infinity, it does not reject it. It returns 1.7976931348623157e+308 — the largest finite double. A value that said "infinity" arrives as a specific, plausible, wrong number.
Nesting limits are nowhere near each other
I binary-searched the deepest array each parser would accept.
| parser | max nesting depth |
|---|---|
| ruby | 100 |
| perl (JSON::PP default) | 512 |
| swift | 513 |
| python | 9,998 |
| jq | 10,000 |
| node | ≥ 199,999 |
Ruby's is a documented default — JSON::State reports max_nesting of 100, and depth 101 raises JSON::NestingError. That is two orders of magnitude below Python and three below Node.
A hundred levels sounds like a lot until you are round-tripping a deeply nested config or a recursive tree from a service written in something more permissive. The producer has no idea the consumer has a ceiling.
Node's number is a floor, not a limit: I stopped searching at 200,000 because the test had stopped being interesting.
What I got wrong
The first version of that table said Perl accepted 199,999 levels, comfortably beating Python and tying Node.
That number was mine, not Perl's. My probe called JSON::PP->new->max_depth(1e9)->decode($t) — I had explicitly raised the limit while writing the harness, then reported the result as if it were the default. JSON::PP out of the box accepts 512 and rejects 513.
So I had configured the parser to be more permissive than anyone would get by default, measured that, and written it down as a property of the library. The fix is one line and the number moved by a factor of 390.
It is the same failure I keep finding in my own instruments: the code ran, produced a number, and the number was about my test setup rather than the thing I was testing. Nothing fails loudly when you measure the wrong configuration.
What survived unanimously
Three documents parsed identically everywhere: an empty key {"":1}, an escaped NUL {"s":"\u0000"}, and {"__proto__":{"polluted":true},"x":1}, which every parser treated as an ordinary key with no special meaning.
Two were rejected by all six: a raw tab character inside a string, and single-quoted keys. They disagreed about what to call the error, but they agreed it was one.
That is the whole of the common ground in this sample.
What to do about it
Never allow duplicate keys to be a question. Reject documents containing them at your edge rather than hoping every parser in the chain resolves them the way yours does. The RFC will not help you; it explicitly permits both answers.
Send large integers as strings. Any identifier above 2^53 that touches JavaScript is a lossy round trip, and the loss is silent. This includes Snowflake IDs, most 64-bit database keys, and anything derived from a nanosecond timestamp.
Test the boundary between your services, not each side. Every parser here is individually reasonable. The bugs live in the seams: Node to Ruby on surrogates, anything to Ruby on nesting, Swift to anything on duplicates.
Assume the strictest parser in your stack sets the contract. It usually does, just later than you would like, and usually in production.
The harness is about sixty lines of shell and six one-file scripts, one per parser, each reading a document and printing what it got back. If you want to know what your own stack does, that is the whole experiment — the interesting part is not the code, it is choosing documents ugly enough to make the parsers show their hand.
Top comments (1)