What Your JSON Parser Does When the Spec Says Nothing

I lost an afternoon to an ID that changed by one. A Go service wrote 9007199254740993 into a queue, a Node consumer read it back as 9007199254740992, and the row it pointed at no longer existed. Nothing logged an error. Both sides were parsing valid JSON, both were behaving correctly by the letter of the spec, and the number was simply different on the other side of the wire.

That sent me down a rabbit hole. RFC 8259 is a short document, and a surprising amount of it says, in effect, do whatever you like. So I took fourteen payloads and ran them through PHP 8.5, Node 26, Python 3.14 and Go 1.26 to find out what "whatever you like" means in practice.

The short version: everyone agrees on what is broken, and nobody agrees on what is merely awkward.

Where they agree

Trailing commas, comments, unquoted keys, leading zeros, raw control characters inside strings, a leading byte order mark — every parser I tested rejected all of them. The spec is unambiguous there and implementations follow it. If you have ever been told "JSON is strict", this is the part people mean. Those are also the failures you see in an editor; the ones that reach production usually mean the payload was never JSON to begin with - an HTML error page, a truncated body, a BOM.

The BOM case is worth a footnote because it catches people migrating files off Windows. A UTF-8 BOM is not whitespace and not part of the grammar, so {"a":1} prefixed with EF BB BF fails everywhere. Python at least tells you what to do about it — Unexpected UTF-8 BOM (decode using utf-8-sig) — while Go just complains about an invalid character 'ï', which is the BOM's first byte read as Latin-1 and is a genuinely unhelpful thing to see in a log.

Where they diverge

Payload PHP 8.5 Node 26 Python 3.14 Go 1.26
{"a":1,"a":2} {"a":2} {"a":2} {"a":2} {"a":2}
{"n":9007199254740993} exact ...992 exact ...992
30-digit integer 1.2345678901234568e+29 1.2345678901234568e+29 exact 1.2345678901234568e+29
{"n":1E400} INF, re-encode fails null Infinity rejected
{"n":NaN} rejected rejected accepted rejected
{"s":"\ud800"} rejected preserved preserved replaced with U+FFFD
Nesting limit 512 none at 1,000,000 RecursionError ~100,000 10,001

Four implementations, four columns, and not one row where the awkward cases line up.

Integers past 2⁵³

This is the one that cost me the afternoon, and it is the one worth internalising. JavaScript has no integer type — every number is an IEEE 754 double, which holds integers exactly up to 2⁵³. Feed it 9007199254740993 and you get 9007199254740992 back. Go's encoding/json does the same thing when you unmarshal into interface{}, because it also picks float64.

PHP and Python get it right for different reasons: PHP has a native 64-bit integer type, and Python has arbitrary-precision integers, which is why it is the only one of the four that round-trips a thirty-digit number intact.

None of the four warn you. There is no flag, no error, no truncation notice. The number is just quietly wrong, and it stays wrong all the way into your database.

RFC 8259 anticipated this. Section 6 notes that implementations using IEEE 754 doubles achieve "good interoperability" and that anything beyond that range risks losing precision. So Node and Go are conformant. Conformant and lossy are not mutually exclusive, which is the whole problem.

If your identifiers are 64-bit integers — Discord and Twitter snowflakes, Postgres bigint keys, anything from a distributed ID generator — send them as strings. This is why Twitter's API has shipped both id and id_str for over a decade. It looks redundant until it isn't. If you are still choosing an identifier format, time-sortable formats like UUID v7 and ULID sidestep the issue entirely by being strings from the start.

NaN and Infinity

NaN is not in the JSON grammar. PHP, Node and Go all reject it. Python parses it happily, because json.loads defaults to allow_nan=True.

The dangerous half is the other direction. Python will also emit those values:

>>> json.dumps({"n": float("inf")})
'{"n": Infinity}'

That string is not JSON. Every other parser in the table rejects it. A Python service doing float maths can therefore produce output that its own consumers cannot read, and it will do so without a word of complaint. Set allow_nan=False and take the exception at the point of encoding, where you can still do something about it.

PHP fails more honestly: json_encode returns false outright with Inf and NaN cannot be JSON encoded. Node quietly turns both into null, which is its own kind of lie but at least produces parseable output.

Lone surrogates

"\ud800" is the first half of a surrogate pair with nothing after it. It cannot be encoded as UTF-8 at all. Three different answers:

  • PHP rejects it: Single unpaired UTF-16 surrogate in unicode escape
  • Node and Python preserve it, giving you a string that will explode later when something tries to encode it
  • Go silently substitutes U+FFFD, the replacement character, so your data is now different and nothing said so

Go's behaviour is the one to watch. Corrupting a string quietly is worse than rejecting it, and if you are comparing payloads to find out what changed between two systems, that substitution is invisible until you diff the bytes. A structural JSON diff will show you the changed value; a line-based diff of pretty-printed output often will not.

Nesting depth

This one is a security setting wearing the costume of an implementation detail. Deeply nested JSON is a cheap denial-of-service against a recursive-descent parser, and the four runtimes have wildly different postures:

  • Go caps at 10,000 and returns a clean error: exceeded max depth
  • PHP caps at 512 by default, adjustable via the $depth argument to json_decode
  • Python raises RecursionError somewhere around 100,000 — a crash, not a rejection
  • Node parsed a million levels without complaint

If you accept JSON from anywhere you do not control, the Go posture is the right one and you need to reproduce it yourself on Node.

Duplicate keys: agreement by accident

Every parser I tested resolved {"a":1,"a":2} to 2. Last one wins, universally.

Do not rely on this. RFC 8259 says object names "SHOULD be unique" and then explicitly describes the behaviour when they are not as unpredictable, listing "report an error", "use the last value" and "use the first value" as all being in bounds. The four mainstream runtimes happen to have converged, but streaming parsers, schema validators and hand-rolled implementations have not. Validation is downstream of all of this in any case — it runs on whatever the parser decided the document meant, so a mangled big integer reaches the validator as a perfectly valid number, which is worth holding in mind alongside the JSON Schema keywords that bite. Duplicate keys in a payload mean somebody upstream has a bug, and silently taking the last one hides it.

Run it yourself

Every number above came from these four files. Each one is standalone — no dependencies, no package files, paste and run.

<?php declare(strict_types=1);

$cases = [
    'duplicate keys'   => '{"a":1,"a":2}',
    '2^53 + 1'         => '{"n":9007199254740993}',
    '30-digit integer' => '{"n":123456789012345678901234567890}',
    '1E400'            => '{"n":1E400}',
    'NaN literal'      => '{"n":NaN}',
    'lone surrogate'   => '{"s":"\ud800"}',
    'leading BOM'      => "\xEF\xBB\xBF{\"a\":1}",
    'trailing comma'   => '{"a":1,}',
];

foreach ($cases as $label => $raw) {
    try {
        $value = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        $again = json_encode($value);
        printf("%-17s parsed as %s\n", $label, $again === false ? 're-encode FAILED: ' . json_last_error_msg() : $again);
    } catch (JsonException $e) {
        printf("%-17s rejected: %s\n", $label, $e->getMessage());
    }
}

for ($d = 1; $d <= 100000; $d++) {
    if (json_decode(str_repeat('[', $d) . str_repeat(']', $d)) === null) {
        printf("%-17s rejected at depth %d\n", 'nesting', $d);
        break;
    }
}
const cases = {
    'duplicate keys':   '{"a":1,"a":2}',
    '2^53 + 1':         '{"n":9007199254740993}',
    '30-digit integer': '{"n":123456789012345678901234567890}',
    '1E400':            '{"n":1E400}',
    'NaN literal':      '{"n":NaN}',
    'lone surrogate':   '{"s":"\\ud800"}',
    'leading BOM':      '{"a":1}',
    'trailing comma':   '{"a":1,}',
};

for (const [label, raw] of Object.entries(cases)) {
    try {
        console.log(`${label.padEnd(17)} parsed as ${JSON.stringify(JSON.parse(raw))}`);
    } catch (e) {
        console.log(`${label.padEnd(17)} rejected: ${e.message.split('\n')[0]}`);
    }
}

for (const d of [10_000, 100_000, 1_000_000]) {
    try { JSON.parse('['.repeat(d) + ']'.repeat(d)); }
    catch { console.log(`nesting rejected at depth ${d}`); break; }
}
import json

cases = {
    'duplicate keys':   '{"a":1,"a":2}',
    '2^53 + 1':         '{"n":9007199254740993}',
    '30-digit integer': '{"n":123456789012345678901234567890}',
    '1E400':            '{"n":1E400}',
    'NaN literal':      '{"n":NaN}',
    'lone surrogate':   r'{"s":"\ud800"}',
    'leading BOM':      '{"a":1}',
    'trailing comma':   '{"a":1,}',
}

for label, raw in cases.items():
    try:
        print(f"{label:<17} parsed as {json.dumps(json.loads(raw))}")
    except Exception as e:
        print(f"{label:<17} rejected: {type(e).__name__}: {e}")

for d in (10_000, 100_000, 1_000_000):
    try:
        json.loads('[' * d + ']' * d)
    except RecursionError:
        print(f"nesting RecursionError at depth {d}")
        break
package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

func main() {
	cases := [][2]string{
		{"duplicate keys", `{"a":1,"a":2}`},
		{"2^53 + 1", `{"n":9007199254740993}`},
		{"30-digit integer", `{"n":123456789012345678901234567890}`},
		{"1E400", `{"n":1E400}`},
		{"NaN literal", `{"n":NaN}`},
		{"lone surrogate", `{"s":"\ud800"}`},
		{"leading BOM", "{\"a\":1}"},
		{"trailing comma", `{"a":1,}`},
	}

	for _, c := range cases {
		var v interface{}
		if err := json.Unmarshal([]byte(c[1]), &v); err != nil {
			fmt.Printf("%-17s rejected: %s\n", c[0], err)
			continue
		}
		out, _ := json.Marshal(v)
		fmt.Printf("%-17s parsed as %s\n", c[0], out)
	}

	for _, d := range []int{10000, 100000, 1000000} {
		var v interface{}
		s := strings.Repeat("[", d) + strings.Repeat("]", d)
		if err := json.Unmarshal([]byte(s), &v); err != nil {
			fmt.Printf("nesting rejected at depth %d: %s\n", d, err)
			break
		}
	}
}

Note that the Go file will not compile if you paste a literal BOM into the source — the compiler rejects a byte order mark in the middle of a file, so the case uses the  escape. A small joke at the expense of anyone testing BOM handling.

If you just want to check a payload without setting any of this up, paste it into the JSON formatter and validator — it reports the exact line and column when parsing fails, which is most of what you need when a response looks fine to the eye.

What to change on Monday

Transport 64-bit integers as strings. Not "consider it" — do it. This is the only item on the list that silently corrupts data across an ordinary service boundary.

Set allow_nan=False in Python wherever you encode JSON for another system. Failing loudly at the encoder beats emitting a document nobody else can parse.

Decode into json.Number in Go when the payload has numbers you care about precisely, instead of letting interface{} collapse them to float64.

Cap nesting depth on Node if the JSON comes from outside. The other three runtimes already do it for you; Node does not.

And if a value disagrees between two services, check the parser before you check your code. The wire format is the same on both sides; the numbers coming out of it may not be. Choosing formats for a new interface is a related but separate question — JSON versus YAML covers the config side, and CSV versus JSON covers bulk data exchange, where the precision problem shows up again in spreadsheet exports.

More Articles

Your Link Preview Is Broken Because the Crawler Never Saw the Page

Blank or wrong link previews are usually a crawler problem, not a tag problem: bot protection, client-side rendering, WebP images - and how to reproduce it.

23 August, 2026

Same Lightness, 12× the Brightness: HSL vs OKLCH

At HSL lightness 50% the measured luminance swings 12.9x across hue. At OKLCH lightness 0.62 it swings 1.2x. Here is the maths, and what it costs you.

20 August, 2026

Claude Now Watermarks Everything It Writes, and There Is No Flag to Turn It Off

Anthropic swapped the sampler's randomness for a keyed choice. The mechanism, the length threshold, what survives editing, and why there is no opt-out.

16 August, 2026