Skip to content
DevTinyTools

Numbers

Why your JSON numbers change when you format them

You paste a payload, press Format, and an ID ends in a different digit. Nothing was corrupted — the document passed through JavaScript, where numbers are doubles and object keys have an order of their own. Knowing which transformations are unavoidable changes how you handle IDs, diffs and signatures.

9 min readTool: JSON Formatter

The spec allows it; the runtime does not

RFC 8259 does not put a size limit on numbers. It explicitly allows implementations to set limits on range and precision, and then warns that integers outside ±(2^53 − 1) “indicate potential interoperability problems”. That sentence is the whole story: the format permits a 20-digit integer, and the runtime almost everyone parses it with cannot hold one.

JavaScript has exactly one numeric type. JSON.parse converts every number literal to a 64-bit float, so a 19-digit ID becomes an approximation before your code ever sees it. Nothing about the document was malformed — the container was too small.

Where the boundary is

Number.MAX_SAFE_INTEGER is 9007199254740991, which is 2^53 − 1. An integer at or below that is represented exactly. The next integer, 9007199254740992, is 2^53 and is still exact, but 9007199254740993 does not exist as a double — it rounds down to 9007199254740992, and so does every odd number after it until the next power of two.

This is why the failure is so easy to miss: an 18-digit snowflake ID is well inside the range of a 64-bit integer and well outside the range of a double. APIs that use snowflakes — and databases that return 64-bit primary keys — hit this constantly.

What a round trip actually changes

Paste the left column into any formatter, press Format, and you get the middle column. Every row below was produced by running the input through JSON.parse then JSON.stringify.

Document changes caused by a parse and stringify round trip
InputAfter round tripWhy
{"id":9007199254740993}{"id": 9007199254740992}Numbers become IEEE-754 doubles. Integers above 2^53 lose their last digits.
{"b":1,"2":2,"1":3}{"1": 3, "2": 2, "b": 1}Keys that look like array indices are enumerated first, ascending. That is a JavaScript object rule, not a JSON one.
{"a":1,"a":2}{"a": 2}Duplicate member names collapse silently: the last value wins and the others are gone.
{"x":1.0,"y":1e3,"z":-0}{"x": 1, "y": 1000, "z": 0}Numbers are re-printed in their shortest round-trippable form.
"\u00e9\/""é/"Escapes are decoded, then only the ones that must be escaped are written back.

Only the first row is a real information loss. The other four are normalisation: the bytes differ, the meaning does not. That distinction matters when you diff two documents — a diff of formatted output will report changes that no human made.

Object key order is not insertion order

The second row of that table is the one people rarely expect. A JavaScript object enumerates integer-like keys first, in ascending numeric order, then the remaining string keys in the order they were inserted. JSON itself defines no order at all — it is a set of name/value pairs — so any consumer that depends on member order was relying on undefined behaviour to begin with.

If order is part of your payload, it has to live in the data: an array of objects with an explicit position field, not the order the bytes happened to arrive in.

Keeping big integers intact

There are three practical options, in order of how much you should prefer them.

  • Make the field a string in the API. This is what most large platforms do. {"id":"9007199254740993"} is unambiguous, survives every parser, and costs one pair of quotes. Nobody then has to remember the rule.
  • Intercept the parse with a reviver. A reviver can inspect each value before your code sees it. Recent V8 engines — verified on Node 22.22.2 for this article — pass a third argument exposing the raw literal, which is what makes this reliable rather than guesswork:
  • Use a lossless parser. Libraries such as lossless-json keep the original text of each number and hand you a wrapper type. They work everywhere, at the cost of a dependency and a slightly unusual value type.
JSON.parse(text, (key, value, context) => {
  // context.source is the literal exactly as written in the document.
  const raw = context?.source;
  if (typeof value === "number" && !Number.isSafeInteger(value)
      && raw && /^-?\d+$/.test(raw)) {
    return BigInt(raw);
  }
  return value;
});

Feature-detect it rather than trusting it blindly: if context is undefined, the engine predates the feature and the value has already lost its digits — in that case fall back to a string field or a lossless parser.

If you hash or sign JSON

A round trip is not byte-stable, so never re-serialise a payload that was signed. Two documents can be semantically identical and hash differently because of whitespace, key order or number formatting, and a payload that went through JSON.parse and back may no longer match its own signature.

Sign the exact bytes you send: serialise once, hash that string, transmit the same string. On the receiving side, verify before parsing. The JSON Minifier page covers the size side of the same rule.

Frequently asked questions

Is this a bug in the formatter?
No. The change happens inside JSON.parse, before any tool can intervene: the literal is converted to a 64-bit float the moment the document is read. A formatter that claimed to preserve 9007199254740993 as a number would be lying about what the runtime holds afterwards.
Which numbers are safe?
Integers between -(2^53 - 1) and 2^53 - 1, that is -9007199254740991 to 9007199254740991. Everything in that range maps to a double with no loss. Above it, consecutive integers collapse onto the same value and Number.isSafeInteger returns false.
Do I lose precision in Python or Go too?
Python's json module parses integers as arbitrary-precision int by default, so it keeps the value. Go's encoding/json decodes into float64 by default, so it loses the same way V8 does. The lesson is that the behaviour depends on the parser, not on the JSON text.
Can I just stringify and parse back to fix it?
No — that is the round trip that caused the loss. The value is already a double; stringifying it writes the corrupted digits out. You have to stop it from becoming a double in the first place, either by treating the field as a string or by intercepting the parse.