Why JSON.parse changes big numbers, and how to keep every digit

By TechCompare · Updated · How we check

JavaScript stores numbers as doubles, so a whole number past 2^53-1 can change when JSON.parse reads it. Send long IDs as strings, because a string keeps every digit.

A JSON file can hold a whole number with as many digits as you like, but JavaScript can't keep every digit of one. Parse a long ID with JSON.parse and it may come back with its last digits changed. No error appears, and the file stays valid JSON, so the change is hard to spot until something downstream doesn't match.

Topic
Large numbers in JSON
json-large-numbers
Category
Troubleshooting
JSON knowledge base

How this is calculated

Say a script reads this file with JSON.parse and writes it back with JSON.stringify. The first block is the input, and the second is what comes out.

Written in the file
{"id":9007199254740993,"p":1.10}
What JSON.parse then JSON.stringify gives
id: 9007199254740992
p: 1.1

Two things changed. The trailing zero in p is the simple one: a zero at the end of a decimal adds no value, so JavaScript writes the stored number back without it. The id changed for a different reason, a limit on how JavaScript stores numbers.

JavaScript stores numbers as doubles, a fixed-size binary format. A double can hold every whole number up to a limit exactly, but not every whole number past it. Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991, or 2^53-1, which is that limit. MDN calls integers in that range safe, and its example shows the trap: Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 is true. Past that limit, the doubles get further apart. Near the sample's id, the doubles are 2 apart, so an odd whole number like that has no double of its own. It rounds to a neighbor, the value shown in the second block.

The JSON text isn't the problem. RFC 8259 puts no limit on how many digits a number can have, and it lets each parser set its own limits on range and precision. The RFC calls integers from -(2^53)+1 to 9007199254740991 interoperable, so readers can be expected to agree inside that range. Outside it, they may not. Two more effects come from the same limits. A literal past about 17 significant digits is rounded, so pi written as 3.14159265358979323846 comes back as 3.141592653589793. A number past the double range, like 1e400, reads as Infinity, and JSON.stringify writes that as null.

Your options, starting with the lowest risk:

  • Send long IDs as strings, like {"id":"9007199254740993"}. The digits stay as text. MDN lists strings as one way to move large numbers without losing precision. The code that reads the value has to treat it as text, not as a number.
  • In JavaScript, use a reviver. The reviver is the second argument to JSON.parse. Its third argument, context, has a source property with the number's text as written, so for a whole number, pass that text to BigInt(). context is a newer feature, so check your runtime first. JSON.stringify throws a TypeError on a BigInt, so turn the value back into text before you write the output. MDN's BigInt page covers that.
  • In Python, json.loads reads a whole number as an int, so every digit stays. For decimals, parse_float=decimal.Decimal keeps the text as written. The Python json docs cover both.
  • Check the digits in the raw text. When an ID matters, search the file for it rather than trusting a value your code parsed.

This tool doesn't round anything. It reads each number by its written digits, and Format and Minify keep the digits as written. Tree, Query and Compare read numbers the same way. Compare treats two IDs that differ in the last digit as different values, even when JavaScript would read them as the same double. The tool shows the digits, but it can't change what your code does with them. The tree viewer shows each value as written, JSONPath queries can filter on a long ID, and the compare page explains how a structural diff counts numbers. Formatting the sample gives this:

Formatted
{
  "id": 9007199254740993,
  "p": 1.10
}

Verdict

Avoid a plain JSON.parse and JSON.stringify round trip on any file where the digits matter, because it changes some values without a warning. Edit the text directly, or use a tool that reads numbers by their written digits. If your code has to parse the number, use the reviver and test it on one long ID before you ship.

More JSON scenarios

Frequently asked questions

Why does JSON.parse change my number when the file is fine?
The file holds the exact digits, but JavaScript reads the number into a double. Past Number.MAX_SAFE_INTEGER, which is 9007199254740991, a double can't hold every whole number, so JSON.parse stores a neighboring value. Your code gets that value, and the file on disk doesn't change.
Should I send big IDs as strings?
Yes, for any ID that could go past the safe limit, since the text survives the trip intact. A JSON Schema can say the field is a string. The cost falls on the reader, which has to treat the ID as text and convert it before doing any math or sorting by number.
Why does a trailing zero disappear in JSON numbers?
A double stores the value, not the way you wrote it, so the trailing zero has no place to stay. For most data that's harmless. It matters when the exact text is part of the data, like a signed payload or a price shown with two decimals. In those cases, keep the value as a string.
Does this tool round big numbers?
No. The JSON Formatter reads each number by its written digits, so Format, Minify, Tree, Query and Compare never round the id in the example or rewrite its decimal. The tool can't change what your own code does, though. If your script runs JSON.parse on the same file, the rounding still happens there.