The JSON numbers JavaScript quietly corrupts
7 min read
Paste this into a browser console:
JSON.parse('{"id": 9007199254740993}').id
You get 9007199254740992. Off by one. No error, no warning, no truncation notice. The value you asked for is gone and something that looks just like it is standing where it was.
This is not a bug in JSON.parse. It is doing what the spec says. But it means that any JavaScript program that reads JSON, touches it, and writes it back is a program that can silently change your data, and most of them do not know it.
Why it happens
JSON has one numeric type and no size limit. The grammar is happy to describe a four-hundred-digit integer.
JavaScript also has one numeric type: the IEEE-754 double. It has 53 bits of integer precision. Past Number.MAX_SAFE_INTEGER, which is 9007199254740991, consecutive integers stop being representable, and the parser rounds to whatever double is nearest.
So JSON.parse maps an unbounded numeric grammar onto a bounded numeric type. There is no way to do that without losing something, and the standard's choice is to lose it silently.
The three ways it bites
Value loss is the famous one. It is not the only one, and it is not the most common.
1. Large integers round
JSON.parse('{"id": 1071004833782874112}').id
// 1071004833782874100
The last two digits are invented. That is a Discord-style snowflake ID, and the same applies to Twitter/X IDs, most database bigints, Stripe-style numeric identifiers, and anything else that packs a timestamp into a 64-bit integer.
The insidious part is that IDs are exactly the values where a near-miss is worse than a crash. A rounded price is obviously wrong. A rounded ID is a valid-looking ID that points at nothing, or worse, at something else.
2. Overflow becomes null
JSON.parse('{"v": 1e400}').v
// Infinity
JSON.stringify(Infinity)
// "null"
Read a number too large for a double and you get Infinity. Write it back out and JSON.stringify turns Infinity into null, because JSON has no infinity literal.
So a round trip converts a number into a null. Downstream, that is not a precision problem. It is a type change, and whatever consumes the field next is going to be surprised.
3. Formatting is destroyed even when the value survives
JSON.stringify(JSON.parse('{"v": 4.0}'))
// {"v":4}
4.0 and 4 are the same number. They are not the same document.
If you are round-tripping a config file, a fixture, an API contract, or anything a human wrote and will read again, 4.0 becoming 4 is a diff. It shows up in code review. It shows up in a git blame. Version-controlled JSON that a formatter "cleaned" is a classic source of noise commits.
The same goes for 1.00, -0 becoming 0, and 5e+3 becoming 5000.
And all three are silent
Nothing throws. Nothing logs. The document parses, the program runs, and the tests pass because the fixtures were generated by the same broken round trip.
You find out when a customer reports that a record will not load, and the ID in your logs is one digit off from the ID in theirs.
What most tools do about it
Nothing, mostly.
Every JSON formatter, viewer, and prettifier that works by calling JSON.parse and then JSON.stringify has all four of these behaviors. That is the standard implementation. Paste a Discord message dump into a random online JSON formatter and the IDs come back wrong.
The workarounds people reach for:
- Parse to
BigInt. Handles large integers, does nothing for4.0or1e400, and gives you a type that will notJSON.stringifywithout a custom replacer. - Parse numbers as strings. Safe, and it breaks every consumer that expected a number.
- Ask the API for string IDs. Correct, and most public APIs already do this for exactly this reason. Also not available to you when you are just looking at somebody else's payload.
What we do in Prettify
The rule is: preserve the token, not the value.
When parsing, each numeric token gets one question asked about it. Would writing this back out produce the exact bytes I read? If yes, it stays an ordinary JavaScript number. If no, it becomes a wrapper that holds the original digit string.
The wrapper type and the two helpers below come from lossless-json. The policy around them is ours, and it is the part worth copying.
function parseNumber(value: string): number | LosslessNumber {
if (isSafeNumber(value) && String(parseFloat(value)) === value) {
return parseFloat(value)
}
return parseLosslessNumber(value)
}
Two conditions, because there are two independent failure modes, and this is the part that most implementations miss.
isSafeNumber catches value loss. It does not catch formatting loss: it says 4.0 is perfectly safe, because numerically it is. 4.0 and 4 are the same double.
That is why the second check exists. String(parseFloat("4.0")) is "4", which is not "4.0", so the token gets preserved even though nothing about its value was ever at risk.
Checking only isSafeNumber is the natural implementation and it silently reformats your document.
Wrap only what needs wrapping
The other half of the design is that most numbers are not wrapped. 1, 42, 0.1, -17.5 all stay ordinary numbers, because they round-trip exactly. Only the tokens that would change get the wrapper.
This matters for a reason that is more practical than philosophical: a wrapper is typeof === 'object'. Every typeof x === 'number' check in the codebase is false for it, and every "is this a container?" check is true for it unless you exclude it. Wrapping everything would mean auditing every numeric code path in the application. Wrapping only unsafe tokens keeps the blast radius to the small set of documents that actually contain them.
Viewing is not computing
Preserving a number exactly and doing arithmetic on it are incompatible goals in a language with one float type. So the two are separated by policy:
- View, select, copy, round-trip: exact. The digits you pasted are the digits you get back.
- Compute: coerced, and you are told. Arithmetic, aggregation, and comparison flatten to a float, because they have to. When that happens, the app says so.
That last clause is the whole point. The original sin of JSON.parse is not that it loses precision. It is that it loses precision without mentioning it. An answer you know is approximate is a usable answer. An answer you believe is exact and is not is a landmine.
The policy covers JSON and JSONL. YAML and CSV go through their own parsers and get ordinary JavaScript numbers, with all three behaviors above.
How to check whether this affects you
Fastest test, in any JavaScript runtime:
const original = yourJsonString
const roundTripped = JSON.stringify(JSON.parse(original))
// If these differ, your pipeline is changing documents.
That catches all three cases at once, formatting included.
If it comes back different, the questions worth asking are: does anything downstream compare these documents, diff them, sign them, hash them, or use a numeric field as a key? If yes, you have a real problem rather than a cosmetic one.
The short version
JSON.parsesilently rounds integers past90071992547409911e400round-trips tonull, changing the type4.0round-trips to4, changing the document- None of it warns you
- The fix is to preserve the original token when re-serializing it would differ, and to check formatting as well as value
Prettify preserves these numbers byte for byte, in JSON and JSONL, in the browser: paste a payload with a snowflake ID into the JSON viewer and the digits come back unchanged. The jq engine applies the same policy, and flags any query that had to coerce.