JSON number precision — why 19-digit IDs come back wrong

Here is one line of JSON, parsed and re-serialised, changing a digit on the way through. No error, no warning, nothing to catch:

in:  {"id": 9007199254740993}
out: {"id": 9007199254740992}

It is tempting to conclude that JSON is lossy. It is not. Run the same document through Python or Ruby and the ID survives intact — which means the damage was done by a choice the JavaScript engine made, and that the standard explicitly permitted.

One document, three answers

Every result below was produced by running the code, not by reading documentation. Three commands, one payload:

node    -p 'JSON.parse("{\"id\":9007199254740993}").id'
python3 -c 'import json;print(json.loads("{\"id\":9007199254740993}")["id"])'
ruby    -rjson -e 'p JSON.parse("{\"id\":9007199254740993}")["id"]'
RuntimeWhat you get backType
Node v24.18.0 (V8 15.0)9007199254740992number
CPython 3.10.119007199254740993int
Ruby 2.6.109007199254740993Integer

Two of the three are exact. The odd one out is the language most JSON is written in, and it is the only one of the three whose integers are not arbitrary-precision: JavaScript has one numeric type, a 64-bit float, so anything above 2**53 - 1 is rounded on the way in. A 19-digit snowflake ID, a nanosecond timestamp, an Ethereum amount — all of them sit well past that line.

The damage can happen before JSON is involved

In JavaScript the rounding is not a property of the parser. It is a property of the number, and it applies to literals in your source too:

9007199254740993 === 9007199254740992   // true
1.0000000000000000001                   // 1

So by the time JSON.parse is called, a numeric ID may already have been corrupted by the code that built the value. This is why "just use a bigger type" does not work in JavaScript: the bigger type does not exist, and the literal syntax will not warn you.

What the standard actually promises

RFC 8259 does not require a lossy parser. It permits one. It allows implementations to set their own limits on range and precision, and the only numbers it promises will interoperate are those inside the window [-(2**53)+1, (2**53)-1]. It names the smell itself: A JSON number such as 1E400 or 3.141592653589793238462643383279 may indicate potential interoperability problems.

That, in one sentence, is the whole problem. JSON defines a grammar for numbers, not a numeric type. The grammar says a number is an optional minus sign, digits, an optional fraction and an optional exponent. It says nothing about how many digits matter or what they mean once decoded. Every implementation then binds that grammar to whatever number type it already had, and the binding is where your ID dies.

It applies to keys as well

The same laissez-faire applies to object keys. RFC 8259 says The names within an object SHOULD be unique, and then concedes what happens when they are not: the behavior of software that receives such an object is unpredictable. All three runtimes I tested take the last value, but nothing in the standard requires that, which is why a duplicated key is a live security question rather than a style question — a proxy and the service behind it can disagree about which value the document means.

Reading is only half of it

The write side is worse, because it destroys information that was never in doubt. These are measured, not theoretical:

ValueJavaScriptPythonRuby
1.011.01.0
1.101.11.11.1
NaNnullNaN — invalid JSONraises GeneratorError
InfinitynullInfinity — invalid JSONraises GeneratorError
-0.00-0.0-0.0
2**53 + 1900719925474099290071992547409939007199254740993

Three things are worth pulling out of that table.

JavaScript turns NaN and Infinity into null. Not an error, not a string — null, which is indistinguishable from a genuine absent value. Python takes the opposite risk and writes NaN into the output, producing a document that is not valid JSON and that a strict reader will reject. Ruby refuses to write it at all, which is the correct behaviour and the least popular one.

1.10 loses its trailing zero everywhere. Not because a parser is lazy, but because 1.10 and 1.1 are the same IEEE 754 double — the trailing zero was never a value, only spelling. Only a reader that declines to convert to a double keeps it. That is the real dividing line in this whole subject: did the parser commit to a floating-point type, or did it keep the text?

The round trip is not a function. If you cannot recover your input from parse(stringify(x)), then comparing serialised JSON as text for equality is a bug waiting to happen. 1.0 becomes 1, -0.0 becomes 0, and 2**53 + 1 becomes 2**53 — all before you look at it.

The sign of zero shows how per-language this gets. Given the source text -0, JavaScript keeps the negative zero when parsing and then writes it back as 0; Python reads the very same token as the integer 0, losing the sign on the way in rather than on the way out. Identical bytes, two languages, two different places to drop the same bit.

And it silently discards non-numbers too

While we are on the write side, JSON.stringify in JavaScript does the following without complaint:

InputOutput
{ a: undefined, b: 1 }{"b":1} — the key disappears
[undefined][null] — in an array the slot survives as null
new Map([[1, 2]]){} — every entry gone, no error
new Set([1]){}
1n (BigInt)throws TypeError
new Date(0)"1970-01-01T00:00:00.000Z", and parsing it back gives a string, not a Date

Note the inconsistency between the first two rows: the same undefined is dropped from an object and replaced with null in an array. Neither is wrong per the standard — an array cannot have a hole, so something has to go there — but a reader who assumes symmetry will be surprised.

The 1e21 cliff

One more JavaScript-only oddity, because it breaks text diffs rather than values:

JSON.stringify(1e20)   // "100000000000000000000"
JSON.stringify(1e21)   // "1e+21"

Below 1e21 a number prints in full; at and above it, exponential notation. The value is identical, the bytes are not. Any tool that compares two payloads by their text — a cache key, a deduplication hash, a diff — will declare these different while any parser will say they are the same.

The escape hatch almost nobody knows

There is now a way to make JSON.parse and JSON.stringify round-trip byte-for-byte in JavaScript, and it is barely two years old. It comes from the TC39 JSON.parse source text access proposal, which is at stage 4, and it does two things: it gives the reviver a third argument carrying the original source text of each value, and it adds JSON.rawJSON for writing that text back out untouched.

That is enough to preserve a number exactly:

const keep = (key, value, ctx) =>
    ctx && 'source' in ctx ? JSON.rawJSON(ctx.source) : value;

const src  = '{"id":9007199254740993,"amount":1.10,"tag":"x"}';
const back = JSON.stringify(JSON.parse(src, keep));

// back === src  ->  true

Without the reviver the same transform silently rewrites the document — a changed digit and a reformatted decimal:

plain:  {"id":9007199254740992,"amount":1.1,"tag":"x"}
kept:   {"id":9007199254740993,"amount":1.10,"tag":"x"}

It works inside arrays too, and JSON.isRawJSON tells you when you are holding one of these wrappers. Two things to know before reaching for it. It is not a number — you cannot do arithmetic on it, so it belongs in pass-through code (a proxy, a logger, a formatter) and not in business logic. And the argument must be valid JSON on its own: JSON.rawJSON('01') throws immediately rather than corrupting anything downstream, which is a good failure to have.

Availability is the real limit. It is present in the runtime I tested, Node v24.18.0 on V8 15.0, and absent from older ones — check with typeof JSON.rawJSON before relying on it. On an engine without it, no reviver will help you, because the digits were gone before your callback ran.

What to do instead

  1. Send identifiers as strings. This is what every large API eventually does, and it is the only fix that holds across languages, proxies and databases simultaneously. "9007199254740993" is exact everywhere; 9007199254740993 is exact in two of the three runtimes above and wrong in the third. A string is also self-documenting: nobody will accidentally sum it.
  2. If you must keep numbers, parse with a reader that does not commit to a double. Python's default already keeps integers exact, and parse_float=decimal.Decimal keeps decimals exact too. Go's json.Decoder.UseNumber() and Jackson's USE_BIG_DECIMAL_FOR_FLOATS are the equivalent switches elsewhere; neither was executed here, so treat their exact semantics as documented rather than measured. In JavaScript, the reviver above is the option that exists today.
  3. Refuse to write NaN and Infinity. Python needs json.dumps(..., allow_nan=False) to stop producing invalid JSON; Ruby does it already. In JavaScript there is nothing to enable — they are silently null, so the check has to be yours.
  4. Never compare serialised JSON as text. 1.0 versus 1 and 1e20 versus 100000000000000000000 are the same values in different spelling. Compare parsed structures, and if you need a stable hash, canonicalise first — which means deciding what "equal" means for numbers before you hash, not after.

Where this bites in practice

The failure is concentrated in identifiers, and identifiers are exactly the values people are most reluctant to make strings because of how much legacy code compares them. Snowflake IDs from Twitter, Discord and the like; nanosecond timestamps at 19 digits; Ethereum amounts in wei, up to 78 digits; 64-bit hashes used as keys. Every one of them is past 2**53, and every one of them fails quietly — you get a valid-looking number that is off by a little, and the error surfaces three services later as "record not found".

Check your own payload

Paste the document into the JSON formatter to see the structure, and be aware of what you are looking at: this tool parses with your browser's engine, which is a V8-family parser, so a 19-digit ID will already be rounded when it appears on screen. That is not a limitation to hide — it is the same thing JSON.parse will do to that data in your application, demonstrated before you ship it. If the number looks wrong in the formatter, it is wrong in your code.

For documents that will not parse at all, the validator reports the line and column, and the syntax cheat sheet covers what the grammar allows.

Provenance

Every result in this article was produced by executing the code shown, on Node v24.18.0 (V8 15.0.245.15), CPython 3.10.11 and Ruby 2.6.10. The quoted sentences are from RFC 8259, The JavaScript Object Notation (JSON) Data Interchange Format, sections 4 and 6; the proposal status is from the TC39 JSON.parse source text access repository. Where a claim was not executed here — the Go and Jackson settings — it is labelled as documented behaviour rather than measured, because the difference between those two things is the entire subject of this article.