Why your JSON IDs keep changing value
JSON numbers are unbounded. IEEE 754 doubles are not. Everything else follows from that.
Every claim here is either measured or sourced. Where it is neither, it says so.
Paste this into a browser console:
JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }
The last three digits changed. Nothing threw, nothing warned, and if that value was a Twitter snowflake or a database primary key you now have a different record. This is the single most common way JSON silently damages data, and it happens in the place people least expect: the parser they trust.
Where the boundary actually is
RFC 8259 puts no limit on the size or precision of a JSON number. The grammar allows any number of digits. So 12345678901234567890123456789 is a perfectly valid JSON number, and so is a decimal with two hundred places after the point.
JavaScript has one numeric type for JSON purposes: the IEEE 754 double-precision float. A double has 53 bits of significand, which means it can represent every integer up to 2^53-1 exactly and cannot represent every integer above it. That value is 9007199254740991, and JavaScript exposes it as Number.MAX_SAFE_INTEGER.
Above that, doubles get sparse. The gap between representable integers is 2 up to 2^54, then 4, then 8, doubling each time. So:
9007199254740992 === 9007199254740993 // true
Both of those are the same double. There is no bit pattern for the odd one, so it rounds to its even neighbour. Your ID did not get corrupted by a bug; it landed in a hole in the number line.
The RFC anticipates this. Section 6 says that a number is interoperable if it “round-trips” through IEEE 754 binary64, and notes that implementations which use it “generally… will be interoperable in the sense that implementations will agree exactly on their numeric values.” The word doing the work there is generally.
It is not only about huge integers
Decimals lose precision far earlier and far less visibly:
0.1 + 0.2 // 0.30000000000000004
JSON.parse('{"v": 1.005}') // { v: 1.005 }, but 1.005 * 100 is 100.49999999999999
The classic monetary bug. A price stored as 1.005 cannot be represented exactly as a double, so rounding it to two places gives 1.00 rather than 1.01. This is why financial systems store money in minor units as integers, or as decimal strings, and never as JSON floats.
There is also a quieter one. A JSON document containing 1.0 becomes the number 1 in JavaScript, and serialising it back produces 1. The document changed. For most purposes that is fine; for a document you are hashing, signing or diffing, it is not.
What each language does
The behaviours diverge more than most people expect, and knowing which side you are on decides your fix.
| Language | Default for a large integer | Can you keep the digits? |
|---|---|---|
| JavaScript | Rounds to the nearest double, silently | No. JSON.parse has no hook that sees the original text |
| Python | Arbitrary-precision int, exact |
Yes, automatically. parse_int and parse_float receive the raw text |
| Go | float64 by default |
Yes. Decoder.UseNumber() keeps the text as a json.Number |
| Java (Jackson) | Integer, Long or BigInteger as needed |
Yes, and USE_BIG_INTEGER_FOR_INTS forces it |
| Rust (serde_json) | u64 / i64 / f64 |
Yes, with the arbitrary_precision feature |
| PHP | int up to PHP_INT_MAX, then float |
Partly. JSON_BIGINT_AS_STRING keeps them as strings |
| C# | long, decimal or double depending on the parser |
Yes, System.Text.Json exposes the raw text |
The asymmetry is the dangerous part. A Python service writes an exact 19-digit integer, a JavaScript client reads it as something else, and the two systems disagree about a value neither of them ever logged.
The JavaScript problem specifically
JavaScript is the odd one out because JSON.parse gives you no way to intervene. The reviver function runs after the number has already been converted:
JSON.parse(text, function (key, value) {
// `value` is already a double here. The original digits are gone.
return value;
});
There is a TC39 proposal, “JSON.parse source text access”, that adds exactly this: the reviver gets a context object carrying the source text of the value, so you can construct a BigInt from it. It is not universally available yet, so today the options are:
- Parse with a library that tokenises the text itself, which is what this site’s parser does.
- Pre-process the text with a regular expression to quote large integers before parsing. Fragile: a regex cannot tell a number inside a string from a number that is a value.
- Fix it at the source.
The four real fixes, in order of preference
Send large IDs as strings. {"id": "12345678901234567890"}. This is the fix. It costs two bytes per value and it is correct in every language with no configuration. Twitter did this in 2010 by adding an id_str field next to id, and every large platform since has done the same thing. If you are designing an API, do it from the start: an identifier is not a quantity, you never do arithmetic on it, and giving it a numeric type buys nothing.
Use minor units for money. Store 1005 cents rather than 10.05. Integers below 2^53 are exact everywhere, and you have removed the decimal problem rather than working around it.
Use a decimal string for anything where precision is the point. Prices, measurements, coordinates that matter. "lat": "51.5074" is uglier and it does not drift.
Configure your parser, if you cannot change the producer. UseNumber in Go, parse_int in Python, arbitrary_precision in Rust, JSON_BIGINT_AS_STRING in PHP. This works, but it only protects the consumers you control.
What BigInt does and does not solve
JavaScript’s BigInt represents arbitrary-precision integers, so it can hold the value. It cannot help you parse:
JSON.parse('{"id": 12345678901234567890}') // precision already gone
BigInt("12345678901234567890") // exact, if you have the string
And it cannot help you serialise, because JSON.stringify throws on a BigInt rather than guessing whether you wanted a number or a string:
JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt
You have to decide, with a replacer:
JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));
Which lands you back at sending it as a string, which was the right answer to begin with.
How to find out whether you have this problem
Paste a real payload into the validator. Every integer outside the safe range is flagged with the value JSON.parse would give you instead, and every decimal that does not survive a float64 round trip is flagged separately.
If the count is not zero, some consumer of that payload is already reading different numbers from the ones you sent, and has been for as long as the field has existed.
The formatting tools on this site never round-trip through JSON.parse. They re-emit the exact source text of every number, so formatting a document with a 19-digit ID gives back the same 19 digits. That is a low bar. It is also one that most formatters do not clear.