Skip to main content
Fundamentals · 8 min read

JSON and Large Numbers: Where the Precision Goes

Why 9007199254740993 comes back as ...992, what happens to a 19-digit ID, and the three reliable fixes. With measured output, not estimates.

Round-trip this through JSON.parse and you do not get back what you put in:

JSON.parse('{"v":9007199254740993}').v
// 9007199254740992

No error, no warning. The last digit changed. If that value was an order ID, two different orders now have the same one.

The spec and the parsers disagree

RFC 8259 places no limit on the magnitude or precision of a JSON number. The grammar is just an optional minus, digits, an optional fraction and an optional exponent. By the letter of the spec, 9007199254740993 is a perfectly good JSON number and a conforming parser should preserve it.

The spec also says, in as many words, that implementations will vary and that interoperability is best served by staying within IEEE 754 double precision. Which is a polite way of saying: everyone uses doubles, so you should assume doubles.

A double has 53 bits of mantissa. The largest integer it can represent exactly is 2^53 − 1 = 9,007,199,254,740,991 — JavaScript exposes it as Number.MAX_SAFE_INTEGER. Above that, consecutive integers stop being distinguishable: 9007199254740992 and 9007199254740993 are the same double, and the parser hands you the even one.

What actually comes back

Measured, not remembered — this is JSON.parse followed by JSON.stringify in Node 22:

In the JSON textWhat you get backWhat happened
90071992547409939007199254740992One past the safe limit. Off by one, silently.
12345678901234567891234567890123456800A snowflake-style ID. The last three digits are invented.
1234567890123456789012345678901234567000Worse the longer it gets.
0.12345678901234567890.12345678901234568Fractions truncate at the same 17 significant digits.
1.01Not precision loss — there is only one numeric type, and the trailing zero is not stored.
1e31000Likewise, the exponent form is not preserved.
-00Negative zero exists as a double but does not survive JSON.stringify.
1e400nullOverflows to Infinity on parse, and Infinity serialises as null.

Where it bites in practice

  • Snowflake IDs. Twitter/X, Discord and anything using the same scheme emit 64-bit integer IDs. Every one of them is past 2^53. This is why those APIs also return an id_str.
  • Database bigints. A Postgres bigint primary key crosses 2^53 at around 9 quadrillion — fine for a sequence, not fine if the key is a hash or a timestamp-based ID.
  • Money. 19.99 * 100 is 1998.9999999999998, not 1999. Binary floating point cannot represent 0.1 exactly, so 0.1 + 0.2 === 0.3 is false.
  • Nanosecond timestamps. Unix nanoseconds passed 2^53 in 1970 + ~104 days. Any ns-precision timestamp is already unsafe.
  • Checksums and hashes stored as integers rather than hex strings.

Three fixes, in order of preference

1. Send it as a string

The right answer almost every time, and what the large APIs settled on:

{ "id": "1234567890123456789" }

A string round-trips exactly through every parser in every language with no special handling. The objection — "but it is a number" — does not survive contact with the question *what arithmetic do you do on it?* For an identifier the answer is none. It is a label that happens to be spelled with digits, and labels are strings.

2. BigInt, with care

JavaScript's BigInt holds arbitrary-precision integers, and BigInt('1234567890123456789') round-trips exactly. Two sharp edges: JSON.parse never produces one, so you need a reviver or a parsing library that supports it, and JSON.stringify throws a `TypeError` on a BigInt rather than serialising it. You have to handle both directions yourself.

3. A lossless parser

Libraries such as json-bigint and lossless-json keep the original text of each number and hand back an exact representation. Useful when you cannot change the producer — a third-party API you do not control. The cost is that the values are no longer plain numbers, so everything downstream has to cope.

For money, do not use floats at all

Separate problem, same root cause. Store currency as an integer number of minor units (1999 for £19.99) or as a decimal string, and use a decimal library for arithmetic. Floats accumulate error; over a ledger of any size that error becomes a reconciliation failure that is miserable to trace.

How to tell whether you have this bug

Paste the response into the JSON validator or the formatter and compare the output with the raw text. Both parse the document and write it back, so any number that cannot survive a round trip changes visibly in the output. An ID ending in ...800 or ...000 that did not end that way in the original is the tell.

A quicker heuristic while reading a payload: count the digits. Sixteen or more in an integer, and it is worth checking. Everything happens in your browser, so you can safely paste a real production response to find out.