Back to News & Insights
JavaScript September 28, 2026 · 5 min read

I gave 24 awkward JSON documents to 6 parsers. Five behaved the same everywhere.

JSON is the format everyone agrees on. So I wrote 24 awkward but small documents, fed the identical...

I gave 24 awkward JSON documents to 6 parsers. Five behaved the same everywhere.

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.

RFC 8259 says the names within an object "SHOULD be unique" and that behaviour when they are not is unpredictable. It is not kidding.

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 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.

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.

| 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.

| 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 maxnesting 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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation