An ID that changes on its own
An API returns an order with the ID 1839274619283746817. Your JavaScript code parses it, and the ID is now 1839274619283746800. No error, no warning. The last digits are simply gone, and the next request for that order returns a 404 or, worse, another order.
The same thing happens more quietly to prices. 12.50 comes back as 12.5, and 1.0 as 1. The numbers are equal, but the text has changed, which matters for signatures, checksums and diffs.
This guide shows why it happens, which languages are affected, and how to keep numbers exact. Every output below was produced by running the code with Node.js 22, Python 3.13, Java 21 with Jackson, and Go.
Why it happens
JSON itself puts no limit on numbers. 1839274619283746817 and 123456789012345678901234567890 are both valid JSON. The limit comes from the language that reads them.
JavaScript has one number type, a 64-bit floating point double. A double stores 53 bits of precision, so it can represent every integer exactly only up to 2^53 - 1, which is 9007199254740991 (Number.MAX_SAFE_INTEGER). Above that, representable numbers start to have gaps between them, and the gaps grow as the numbers do:
| Range | Gap between representable integers |
|---|---|
| Up to 2^53 (about 9.0 × 10^15) | 1 |
| 2^53 to 2^54 | 2 |
| 2^60 to 2^61 (about 1.2 to 2.3 × 10^18) | 256 |
| 2^63 and above (beyond a signed 64-bit integer) | 2,048 or more |
Our ID sits in the 2^60 range, so it gets rounded to the nearest multiple of 256: the stored value is 1839274619283746816, which JavaScript prints in its shortest form, 1839274619283746800.
The JSON standard knows this. RFC 8259, section 6 notes that most software uses doubles, and that integers inside the range of 2^53 are the ones every implementation will agree on. IDs from databases with 64-bit BIGINT keys, Java long values, and Snowflake-style IDs (used by X and Discord, among others) routinely exceed it.
Seeing it in Node.js
The results of each line are shown as comments:
const text = '{"id": 1839274619283746817, "price": 12.50, "qty": 1.0, "big": 1e2}';
const data = JSON.parse(text);
data.id; // 1839274619283746800
Number.isSafeInteger(data.id); // false
data.id + 1; // 1839274619283746800
JSON.stringify(data); // {"id":1839274619283746800,"price":12.5,"qty":1,"big":100}
// Two different IDs become the same number
JSON.parse('1839274619283746817') === JSON.parse('1839274619283746818'); // true
JSON.parse('9007199254740993') === JSON.parse('9007199254740992'); // true
Three things go wrong at once:
- The value is wrong. Adding 1 does not even change it, because the next representable number is 256 away.
- Different IDs collide. Two records can map to the same key in a
Mapor a cache. - The damage travels.
JSON.stringifywrites the rounded number back out, so every service downstream receives the wrong ID as if it were correct.
Number.isSafeInteger() is the cheap check. If an ID field ever fails it, that field cannot be a JavaScript number.
Trailing zeros and number formatting
Small numbers are safe from rounding, but not from rewriting. Parsing turns the text into a number and forgets how it was written, so serializing it again produces the language's own preferred form:
| Original text | Node.js parse, then stringify | Python loads, then dumps |
|---|---|---|
12.50 | 12.5 | 12.5 |
1.0 | 1 | 1.0 |
0.10 | 0.1 | 0.1 |
1e2 | 100 | 100.0 |
1.5E+3 | 1500 | 1500.0 |
-0.0 | 0 | -0.0 |
The values are mathematically the same, and the two languages do not even agree with each other. This matters whenever the exact text matters:
- Signatures and checksums. A webhook signature is computed over the raw body. Parse and re-serialize the body before verifying, and
12.50becoming12.5breaks the signature. Always verify against the bytes you received. - Money.
12.5displayed as a price looks wrong. Format for display explicitly, and store amounts as integer minor units (1250cents) or as strings. - Diffs and reviews. Reformatting a config file through a parser produces a diff full of numbers nobody changed.
Other languages
The same document, {"id": 1839274619283746817, "price": 12.50}, parsed with each language's standard or most common library:
| Language and library | The 19-digit ID | 12.50 | Keeping both exact |
|---|---|---|---|
JavaScript, JSON.parse (Node.js 22) | Rounded to 1839274619283746800 | 12.5 | A reviver with source access (next section) |
Python 3.13, json | Exact: Python integers have no size limit | 12.5 | json.loads(text, parse_float=Decimal) gives Decimal('12.50') |
| Java 21, Jackson 2.16 | Exact as a Long; larger values become BigInteger | 12.5 as a Double | Enable DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS |
Go 1.24, encoding/json into map[string]any | Rounded: every number becomes a float64 | 12.5 | decoder.UseNumber(), or decode into a struct with an int64 field |
Two details from these tests:
- Python's standard
json.dumpscannot write aDecimalback out:TypeError: Object of type Decimal is not JSON serializable. Thesimplejsonpackage can, and round-trips12.50exactly withuse_decimal=True. - With
USE_BIG_DECIMAL_FOR_FLOATS, Jackson writes the document back as{"id":1839274619283746817,"price":12.50}, unchanged. Go'sUseNumber()does the same.
The pattern: typed backends are usually fine, and JavaScript is where IDs break. A Java or Python service can produce a perfectly correct response that its own web frontend corrupts on arrival.
How to keep numbers exact
1. Send IDs as strings
The most reliable fix is in the API itself. An ID is a label, not a quantity: nobody adds two order IDs. Send it as a string and no client can round it. Discord's API does exactly this, returning its 64-bit IDs as strings to avoid integer overflow in some languages.
In a Java service with Jackson, one annotation does it, and reading the string back into a long still works:
public class Order {
@JsonFormat(shape = JsonFormat.Shape.STRING)
public long id = 1839274619283746817L;
}
// Serializes as {"id":"1839274619283746817"}
2. Read the original text in JavaScript
Modern JavaScript can see the number's source text before it is rounded. The reviver gets a third argument, context, whose source is the original text, and JSON.rawJSON() writes text back out unchanged:
const data = JSON.parse(text, (key, value, context) =>
key === 'id' ? BigInt(context.source) : value);
// data.id === 1839274619283746817n
JSON.stringify(data, (key, value) =>
typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value);
// {"id":1839274619283746817, ...}
This works in Node.js 22. MDN lists JSON.rawJSON() as Baseline since March 2025, and core-js has a polyfill for older browsers. Plain JSON.stringify cannot write a BigInt at all: it throws TypeError: Do not know how to serialize a BigInt, which is why the replacer is needed.
3. Use a lossless parser
If you cannot rely on new browser features, a library can do the parsing. Tested on {"id": 1839274619283746817, "price": 12.50}:
| Library | ID | Price after a round trip |
|---|---|---|
json-bigint 1.0.0 | Exact | 12.5 |
lossless-json 4.3.1 | Exact | 12.50 |
lossless-json keeps the original text of every number, so it also preserves trailing zeros.
4. Treat money as exact decimals
For prices, never let a float near the value. Parse with Decimal in Python, BigDecimal in Java (USE_BIG_DECIMAL_FOR_FLOATS) or UseNumber() in Go. Better still, design the API to send integer minor units ("amount": 1250 for 12.50) or a string ("amount": "12.50").
Formatting without changing a value
The obvious way to format JSON is to parse it, then stringify it with indentation. That round trip is exactly where values change. Here is a one-line response pretty-printed both ways:
Input:
{"id":1839274619283746817,"price":12.50,"qty":1.0,"status":"paid","status":"refunded"}
JSON.stringify(JSON.parse(input), null, 2) | JSON Keyper's Formatter |
|---|---|
"id": 1839274619283746800 | "id": 1839274619283746817 |
"price": 12.5 | "price": 12.50 |
"qty": 1 | "qty": 1.0 |
Only "status": "refunded", the duplicate silently dropped | Both status keys, as sent |
The JSON Formatter on JSON Keyper never re-serializes. It walks the original text and changes only the whitespace outside strings, so big IDs, trailing zeros, key order and even duplicate keys come out exactly as they went in. It still runs a real parse first, so invalid JSON is reported with its line and column instead of being guessed at. Everything happens in your browser, and the JSON is never uploaded.
That makes it safe to pretty-print a response before you read it, without the formatting step becoming the thing that changed the data.
Pretty-Print JSON Without Changing a Value
The formatter changes only whitespace, so big IDs, trailing zeros, key order and duplicate keys come out exactly as you pasted them. Free, in your browser, no login.
Open the JSON Formatter