JSON big numbers

JSON.parse rounds any integer past 9,007,199,254,740,991 and raises nothing at all. A 19-digit ID comes back looking like an ID — just not the one that was sent.

What actually happens

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992              <- final digit changed

JSON.parse('{"v": 12345678901234567890}').v
// 12345678901234567000          <- final three digits gone

Number.MAX_SAFE_INTEGER
// 9007199254740991

Both documents are valid JSON. RFC 8259 places no bound on how many digits a number may have and explicitly leaves range and precision to the implementation. The loss happens entirely inside the parser: JavaScript represents every number as a double, which holds integers exactly only up to 253−1. Past that, the value snaps to the nearest representable double and the parse reports success.

The reason this is costly rather than merely annoying is that the corrupted value remains plausible. A rounded identifier is still a number of the right shape and length, so nothing downstream flags it. It surfaces as a row that cannot be found or a join that silently drops records, often in a different service and days later.

Why the obvious fixes do not work

The first instinct is a reviver, since JSON.parse takes one. It cannot help. The reviver runs after each value has been converted, so it is handed the already-rounded number and never sees the original digits. There is nothing left to recover.

The second instinct is to catch the problem afterwards by checking Number.isSafeInteger. That detects that a value is unsafe but still cannot tell you what it should have been. Any real detection has to read the source text before parsing — which is what the formatter on this site does, scanning the raw input rather than the parsed object precisely because the information only exists there.

What to do instead

Send the value as a JSON string. Quote it in the producer, convert it deliberately in the consumer, and the digits survive every language in between. This is the convention behind Twitter's long-standing id_str field and behind most modern APIs returning snowflake-style identifiers in quotes.

{ "id": 12345678901234567890 }    // lossy in JavaScript
{ "id": "12345678901234567890" }  // exact everywhere

JSON.parse('{"id":"12345678901234567890"}').id
// "12345678901234567890"   <- intact
BigInt(JSON.parse('{"id":"12345678901234567890"}').id)
// 12345678901234567890n

If you cannot change the producer, a big-number aware parser such as lossless-json or json-bigint reads numbers as text and hands back a string or a BigInt. Be aware that BigInt cannot be serialised by JSON.stringify without a replacer, so the conversion has to be explicit on the way out as well as the way in.

The rule that avoids the whole class of bug

Use JSON numbers for quantities you will do arithmetic on, and JSON strings for values whose exact digits carry meaning. Identifiers, account numbers and financial amounts in minor units all fall in the second group: you compare them, index them and print them, but you never add them. Once that line is drawn at the API boundary, the numeric models of the languages on either side stop mattering.

Frequently asked questions

What is the largest integer JSON can hold?

The JSON specification sets no limit at all — RFC 8259 allows arbitrary digits and leaves range and precision to the implementation. The limit belongs to whatever parses the document. JavaScript turns every JSON number into a double, which represents integers exactly only up to 2^53−1, or 9007199254740991, exposed as Number.MAX_SAFE_INTEGER. Beyond that, values are rounded to the nearest representable double. That mismatch is the whole problem: a producer in Java or PostgreSQL can legitimately emit a 64-bit integer, the document is valid, and the JavaScript consumer quietly receives a different number. The specification even warns about this, noting that interoperability is best served by staying inside the range a double can represent exactly.

Does JSON.parse throw on numbers that are too large?

No, and that is what makes this expensive. Parsing succeeds and returns a value that looks entirely normal. We measured two cases: 9007199254740993 comes back as 9007199254740992, and 12345678901234567890 comes back as 12345678901234567000. No exception, no warning, no console message. Because a rounded identifier still looks like a plausible identifier, the failure surfaces much later as a lookup that finds nothing or a record that cannot be matched, by which point the original digits are long gone. Detection has to happen on the raw text before parsing, which is why the formatter on this site scans the input string rather than inspecting the object that comes back. Checking Number.isSafeInteger afterwards tells you a value is unsafe but never what it originally was.

Can a reviver function fix precision loss?

No. The reviver passed as JSON.parse's second argument runs after each value has already been converted, so it receives the rounded number rather than the original text and has nothing to recover from. This surprises people who reach for it as the obvious hook. The genuine options are to change the producer so large values travel as JSON strings, or to use a parser that reads numbers as text — libraries such as lossless-json and json-bigint keep the digits intact and hand back a string or a BigInt. Note that BigInt cannot be serialised by JSON.stringify without a replacer, so the conversion has to be deliberate at both ends. The cleanest fix remains changing the producer, because a parser swap only protects the one consumer you remembered to update.

How should I send a 64-bit ID over JSON?

As a string. Quote it in the producer, parse it deliberately in the consumer, and the digits survive every language in between. This is why Twitter added id_str alongside id years ago and why most modern APIs return snowflake-style identifiers quoted. The same applies to any value where the exact digits matter rather than the magnitude: database bigints, financial amounts in minor units, and counters that may grow past the safe range. Anything you would compare for equality or use as a lookup key belongs in a string. Reserve JSON numbers for quantities you will do arithmetic on. The test is simple: if you would never add two of these values together, it is a label rather than a number, and labels belong in strings.

Do other languages have the same problem?

Not the same one, which is exactly why it hurts in mixed systems. Python parses JSON integers into arbitrary-precision ints, Java's common libraries can map to long or BigInteger, and Go's encoding/json will fill an int64 correctly when the target field is typed that way — though it defaults to float64 when decoding into an interface, reintroducing the issue. So a document can round-trip perfectly through a Python service and lose digits in a JavaScript one. The mismatch is between JSON's unbounded numbers and each language's numeric model, so the safe convention is the string, agreed at the API boundary. Writing an integration test that round-trips a value at the top of your ID range through the real serialiser catches the mismatch before production does.

References

Paste a document into the JSON formatter to see which of its numbers would be rounded. More browser-only tools at withuse.io/tools.