Why big numbers break in JSON, and what to do
An identifier of 19 digits goes into a parser and comes out changed. Here is why, and the three ways to stop it.
Open a browser console and type this:
JSON.parse('{"id": 9007199254740993}').id
The answer is 9007199254740992. The last digit changed. Nothing warned you.
This is not a fault in JSON, and not a fault in the browser. It is what happens when a number too large for the format meets the format. It costs real money every year, because the number that changes is usually an identifier for an order, a payment or a user.
Why it happens
JSON says a number may hold any number of digits. The standard says nothing about how a program must store one.
Almost every program stores it as a 64-bit floating point number, the IEEE 754
double. That holds a huge range, but only about 15 to 17 significant digits.
Above 9007199254740991, which is 2 to the power 53 minus 1, whole numbers
stop being exact. Only every second whole number can be held, then every
fourth, and so on.
JavaScript calls that limit Number.MAX_SAFE_INTEGER. Python and Java read
large whole numbers exactly, because they have a separate integer type, so the
problem is invisible to a Python developer until a JavaScript client reads the
same data.
Where it bites
Twitter. The best-known case. Tweet identifiers passed 2^53, and every
JavaScript client began to show the wrong tweet. The answer was to add
id_str, a text copy of the same identifier. Many APIs have done the same
since.
Snowflake identifiers. Discord, Twitter and others use a 64-bit identifier that holds a timestamp. All of them are above the safe limit. Discord's documentation tells you to treat them as text in JavaScript.
Money in the smallest unit. A total in paise or cents for a large sum can pass the limit.
A timestamp in nanoseconds. 1762244096123456789 has 19 digits. It is not
safe.
A database bigint. Postgres and MySQL both hold 64 bits. Any such column
can produce a value that JavaScript cannot read exactly.
How to see whether you have the problem
The hard part is that nothing fails. The parse works, the program runs, and the number is wrong.
const text = '{"id": 9007199254740993}'
const value = JSON.parse(text)
console.log(value.id) // 9007199254740992
console.log(String(value.id) === '9007199254740993') // false
This viewer shows you the truth. It never calls JSON.parse and never
turns the text into a number. It remembers where the number is in your text
and shows exactly the digits you gave it. Format and minify write the same
digits back out. So you can open a file, look at an identifier and know that
what you see is what the file holds. Most other online viewers show the
changed value.
The three answers
1. Send the number as text — the right answer
{ "id": "9007199254740993", "amount": 1999 }
An identifier is not a number. You never add two identifiers together, and you never take an average of them. It is a name that happens to be written in digits. Text is the correct type for it.
If you cannot change the field, add a second one, as Twitter did:
{ "id": 9007199254740993, "id_str": "9007199254740993" }
Old clients keep working. New clients read the text.
2. Use a parser that keeps the digits
In JavaScript, json-bigint reads large whole numbers into BigInt:
import JSONbig from 'json-bigint'
const value = JSONbig.parse('{"id": 9007199254740993}')
console.log(value.id.toString()) // 9007199254740993
Take care: a BigInt does not mix with an ordinary number in arithmetic, and
JSON.stringify refuses it unless you give it a replacer. Use it at the edge,
turn the value into text at once, and keep text inside your program.
In Python, the standard library already does the right thing. Your risk is only on the way out, to a JavaScript client.
3. Keep the raw text when you pass data through
If your service only forwards data, do not parse the number at all. Many languages can read a number as its original text. That way nothing changes on the way through.
What not to do
Do not round and hope. The value is already wrong before your code sees it.
Do not test with small numbers. Every test passes with {"id": 1}. Put
9007199254740993 in your test data and watch what happens.
Do not trust a tool that shows you the number. If the tool parsed it, it may be showing you a changed value. Check with a tool that keeps the source text, such as this one.
The rule to remember
If a number is an identifier, send it as text.
If you write APIs, make that a rule in your team. It costs one line in a document and it removes a whole class of fault that is very hard to find later.
Floating point, while we are here
The same format causes the other famous surprise:
0.1 + 0.2 // 0.30000000000000004
A value such as 0.1 cannot be held exactly in binary, in the same way that a
third cannot be written exactly in decimal.
So never hold money as a floating point number. Hold the smallest unit as a whole number — paise, cents — or hold it as text and use a decimal library. A sum of a thousand prices in floating point drifts, and an accountant will find it.
Try it on your own file. The tool runs in your browser. Nothing is uploaded, and nothing is stored on a server.
Open the JSON ViewerRead next
Is it safe to paste JSON into an online viewer?
What really happens to your data in a web tool, how to tell an upload from local work, and when to say no.
What is JSON? A complete guide
The whole JSON format in one page: the six kinds of value, the rules, the traps, and what JSON cannot do.