DEV Community

Alexi Taylor 🐶
Alexi Taylor 🐶

Posted on Originally published at prettify.tools

🔢 The JSON Numbers JavaScript Quietly Corrupts

Overview

This post will help identify the ways JSON.parse silently changes your data, and what you can do about it. We will cover why it happens, the three distinct failure modes it produces, how to check whether your own pipeline is affected, and the approach I use in my own tooling to prevent it. Each failure mode includes an example you can run in your browser console right now.

💡 Every example in this post takes less than ten seconds to verify. Open your console and follow along.

📚 Table of Contents

  • Why it happens
  • The three ways it bites
    • Large integers round
    • Overflow becomes null
    • Formatting is destroyed even when the value survives
  • What most tools do about it
  • What we do in Prettify
  • How to check whether this affects you
  • Drawbacks
  • The short version

First, try it yourself

Paste this into a browser console:

JSON.parse('{"id": 9007199254740993}').id
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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 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 for 4.0 or 1e400, and gives you a type that will not JSON.stringify without 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)
}
Enter fullscreen mode Exit fullscreen mode

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.

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.
Enter fullscreen mode Exit fullscreen mode

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.

Drawbacks

Being honest about the limits of this approach:

  • It only covers JSON and JSONL. The policy above applies to Prettify's JSON pipeline. YAML and CSV go through their own parsers and get ordinary JavaScript numbers, with all three behaviors described in this post.
  • Computing still coerces. Preserving a number exactly and doing math on it are incompatible in JavaScript. Arithmetic flattens to a float. The best a tool can do is tell you when it happened.
  • It does not help with data that was already corrupted upstream. If the ID was rounded before it reached you, no parser can recover the digits that were lost.

The short version

  • JSON.parse silently rounds integers past 9007199254740991
  • 1e400 round-trips to null, changing the type
  • 4.0 round-trips to 4, 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.


Happy Coding 🚀


Blog Post: https://prettify.tools/blog/the-json-numbers-javascript-corrupts


If you liked this content, follow me on Twitter @alexi_be3 💙

Top comments (5)

Collapse
 
gautamtalksdev profile image
Gautam K •

Money is where the silent version hurts most, and it doesn't need a big number. I ran into this building a Canadian payroll engine. One CPP line is (500.00 - 350.00) x 0.0595 = 8.925, and the CRA's published formulas round that to 8.93. Do the same multiplication in a normal double and you get 8.924999999999999, which rounds to 8.92. One cent off on a pay stub, no error anywhere.

Same lesson as your IDs: a value that looks right is worse than a crash. For money I'd add one rule to your list: parse amounts as strings or integer cents at the boundary, and never let them become a JS number.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The ID collision version is the one I have actually lived through. A Node service I ran passed through a provider's 64-bit transaction IDs, and JSON.parse rounded the last digits — we spent a day chasing "missing" records that turned out to be two different payments collapsing onto the same rounded ID. We ended up forcing string IDs at the API boundary, which is your "ask the API for string IDs" fix except we had to apply it on our side of a provider we didn't control. The formatting point is the one I had never considered: we round-trip config fixtures through JSON in our test setup, and 4.0 silently becoming 4 explains a phantom diff I once blamed on the fixture generator. Do you think a round-trip equality check belongs in API test suites as a standard assertion, or would it be too noisy for everyday use?

Collapse
 
idanshalem profile image
Idan Shalem •

Another option worth mentioning is JSON.parse's reviver context.source. It preserves the original numeric token even when the parsed Number has already lost precision, so you can recover a large integer from the source rather than from the rounded value. It also distinguishes 4.0 from 4 if preserving the original representation matters. The caveats are that source is only available for primitive values, and older engines need a support check or fallback.

Collapse
 
efe_genc profile image
Efe Genç •

The quick test, comparing JSON.stringify(JSON.parse(original)) with the original, is described as catching all three cases. It also reports a difference on any pretty-printed document, because stringify drops the whitespace, and on \u00e9 escapes, which come back as the literal character. Duplicate keys collapse too. So a differing result does not by itself mean a number changed. Extracting the numeric tokens from both strings and comparing those isolates what you are testing. Does the preserve-the-token policy in Prettify stop at numbers, or does it also keep string escapes and key order and duplicates?