Guide
Why JSON loses large IDs, and how to keep every digit
JSON has one number type and no size limit. JavaScript has one number type and a very real size limit. Where those two facts meet, long identifiers quietly lose their last digits — with no exception, no warning and no failing test.
Two number models that do not agree
JSON, as defined by RFC 8259, has exactly one number type and says nothing about how big it may be or how
many digits of precision it must keep. A JSON document containing 9007199254740993 is perfectly
valid, and so is one containing a number with two hundred digits.
JavaScript also has one number type, and it is an IEEE 754 double. A double carries 53 bits of significand,
which means it can represent every whole number up to 9,007,199,254,740,991 exactly and only some of the
whole numbers above that. JavaScript exposes the boundary as Number.MAX_SAFE_INTEGER.
RFC 8259 is aware of the gap. Section 6 notes that implementations commonly use doubles, and that the best interoperability is achieved by staying inside the range a double can represent exactly. That is advice, not a constraint: nothing stops a server sending a larger number, and nothing in the JSON grammar makes the result invalid.
The format allows a number the receiving language cannot hold. No error is raised at the boundary, because from JSON's point of view nothing went wrong.
What the damage actually looks like
Parse an integer above the safe range and you get back the nearest double, silently:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992 <- the last digit changed
JSON.parse('{"id": 12345678901234567890}').id
// 12345678901234567000
There is no exception, no warning and no flag to check afterwards. The value looks like a number, it is a number, and it is the wrong number. Round-tripping makes it permanent: parse the document, change one unrelated field, stringify it again, and the corrupted identifier is now what you send back.
This is not hypothetical. Sixty-four-bit identifiers are the normal shape of a modern ID: snowflake-style IDs, chat platform message IDs, database sequences past a few billion rows. Platforms that hit this problem early all solved it the same way, by returning the identifier twice, once as a number and once as a string, and telling browser clients to use the string. That duplication exists purely because of this mismatch.
The failure mode is unusually nasty because it is selective. Most identifiers in a payload are fine. Only the ones whose binary representation needs more than 53 significant bits shift, and they shift by a small amount, so the result still looks like a plausible ID. A test fixture with small IDs passes. Production does not.
How to spot it before production does
The cheapest check is a round-trip comparison. If a document survives parse-then-stringify with its digits intact, no number in it lost precision:
function losesPrecision(text) {
const reparsed = JSON.stringify(JSON.parse(text));
return reparsed.replace(/\s/g, '') !== text.replace(/\s/g, '');
}
That is blunt — key order and formatting differences produce false positives — but as a canary over a captured API response it is fast, and it does not lie in the direction that matters: if the strings match, nothing was lost.
A second check is to scan for the numbers at risk before parsing. Any unquoted integer of 16 digits or more is worth a look, because 16 is the first length at which a value can exceed the safe range.
The JSON Formatter and JSON Validator on this site take a third approach: they keep the original text of every number token and re-print it, rather than converting it to a JavaScript number at all. Formatting a payload here cannot change a digit, which is the main reason they were written that way.
Four fixes, in order of preference
1. Send identifiers as strings
The only fix that removes the problem rather than working around it. An identifier is not a quantity: you
never add two IDs together or take their average. Nothing is lost by writing
"id": "9007199254740993", and every language on both sides of the wire handles a string
identically. If you control the API, do this and stop reading.
2. Parse with a lossless parser
When you do not control the payload, use a parser that does not route integers through a double. Libraries
such as json-bigint and lossless-json produce BigInt values, or their
own number wrapper, for values outside the safe range. The cost is that those values are no longer plain
numbers: arithmetic and JSON.stringify both need care, and BigInt cannot be mixed
with Number in the same expression.
3. Quote the dangerous fields before parsing
A pragmatic middle path when only a few fields are affected and you cannot add a dependency: rewrite the raw text so the known-large fields are quoted before it reaches the parser. This is string surgery on a structured format, so it is only defensible when the shape of the payload is known and stable. Do not let it become the general solution.
4. Reach for the reviver — and learn why it will not help
The obvious idea is to pass a reviver function to JSON.parse and repair large numbers there.
It does not work: the reviver is handed the value after it has been converted to a double. By the
time your function runs, the digits are already gone. This catches people out often enough to be worth
stating plainly.
Newer JavaScript engines address exactly this, with an extension that gives the reviver access to the original source text of the value it is called for. Where it is available it is the cleanest fix in this list. Check support in the engines you actually target before relying on it.
The same problem outside the browser
It is easy to assume this is a JavaScript problem. It is a double-precision problem, and it appears wherever a JSON value is parsed into a floating point type.
| Environment | Default behaviour for a large integer |
|---|---|
| JavaScript | Always a double. Precision is lost above 9,007,199,254,740,991. |
| Python | Integers are arbitrary precision, so json.loads keeps every digit. |
| .NET, System.Text.Json | Safe when the target property is long or decimal. Reading the same value as double loses digits. |
| Java, Jackson | Integral values map to int, long or BigInteger by size, so they survive — until something declares the field as double. |
The pattern is consistent: languages with a separate integer type keep the digits, and the loss happens at whichever boundary chooses a floating point type. That boundary is often not the parser. It can be an ORM column mapping, a dynamic type, a serialiser on the way back out, or a spreadsheet the data passes through.
It also means a payload can survive several hops and be corrupted by the last one. If you are tracing where
digits were lost, test each hop with a value you know is unsafe. 9007199254740993 is a good
one, because the wrong answer is easy to recognise.
What to take away
- Any unquoted JSON integer above 9,007,199,254,740,991 is at risk the moment JavaScript touches it.
- The loss is silent by design. There is no error to catch and no flag to check.
- If you own the API, send identifiers as strings. Everything else is mitigation.
- If you do not, use a lossless parser, and remember that a
JSON.parsereviver runs too late to help. - Check every hop, not just the parser. Floating point types get chosen in more places than you expect.