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:

RangeGap between representable integers
Up to 2^53 (about 9.0 × 10^15)1
2^53 to 2^542
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 Map or a cache.
  • The damage travels. JSON.stringify writes 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 textNode.js parse, then stringifyPython loads, then dumps
12.5012.512.5
1.011.0
0.100.10.1
1e2100100.0
1.5E+315001500.0
-0.00-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.50 becoming 12.5 breaks the signature. Always verify against the bytes you received.
  • Money. 12.5 displayed as a price looks wrong. Format for display explicitly, and store amounts as integer minor units (1250 cents) 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 libraryThe 19-digit ID12.50Keeping both exact
JavaScript, JSON.parse (Node.js 22)Rounded to 183927461928374680012.5A reviver with source access (next section)
Python 3.13, jsonExact: Python integers have no size limit12.5json.loads(text, parse_float=Decimal) gives Decimal('12.50')
Java 21, Jackson 2.16Exact as a Long; larger values become BigInteger12.5 as a DoubleEnable DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS
Go 1.24, encoding/json into map[string]anyRounded: every number becomes a float6412.5decoder.UseNumber(), or decode into a struct with an int64 field

Two details from these tests:

  • Python's standard json.dumps cannot write a Decimal back out: TypeError: Object of type Decimal is not JSON serializable. The simplejson package can, and round-trips 12.50 exactly with use_decimal=True.
  • With USE_BIG_DECIMAL_FOR_FLOATS, Jackson writes the document back as {"id":1839274619283746817,"price":12.50}, unchanged. Go's UseNumber() 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}:

LibraryIDPrice after a round trip
json-bigint 1.0.0Exact12.5
lossless-json 4.3.1Exact12.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 droppedBoth 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