JSON Number Precision - How Big IDs Silently Corrupt
A JSON payload looks clean, the parser throws no error, and yet a user ID that ended in ...974912 comes back as ...974900 — and every lookup against it 404s. This is not a bug in your code; it is a collision between what the JSON spec allows and what JavaScript's number type can hold. This article walks through exactly how large integers and decimals get silently mangled, how to detect it, and how to fix it in JavaScript, Python, and Go.
The core bug: arbitrary precision meets IEEE-754
The JSON specification (RFC 8259) places no limit on the size or precision of a number. 7203371950759974912 is a perfectly legal JSON number, and so is 0.30000000000000004. The grammar describes numbers as text; it says nothing about how a parser must store them.
JavaScript made a choice that most other languages copied or had to work around: every JSON number is parsed into a Number, which is an IEEE-754 64-bit double. A double has 52 bits of mantissa, which means it can represent every integer exactly only up to 2^53 − 1:
Number.MAX_SAFE_INTEGER; // 9007199254740991
Above that boundary, integers start skipping. There is no error, no warning — the parser simply rounds to the nearest representable double. Snowflake-style IDs (used by Twitter/X, Discord, Instagram, and many internal systems) are 64-bit integers that routinely exceed 19 digits, far past the safe zone:
JSON.parse('{"id":7203371950759974912}');
// { id: 7203371950759974900 }
// ^^^ last three digits are now wrong
The parsed value 7203371950759974900 is the closest double to the real ID. It is off by 12. When you put it in a URL — /api/users/7203371950759974900 — the record does not exist, and you get a 404 for a user who is sitting right there in the database.
A debugging walkthrough: where did the digits go?
Imagine you call an API and get back what looks like a valid response:
{ "id": 7203371950759974912, "name": "ada", "balance": 0.1 }
You parse it, pass id to a follow-up request, and the follow-up 404s intermittently — but only for some users. Here is how to confirm the cause.
First, never trust the parsed number; inspect the raw text against the parsed result. The truncation is invisible until you compare them side by side:
const raw = '{"id":7203371950759974912}';
const parsed = JSON.parse(raw);
console.log(raw.includes("7203371950759974912")); // true (in the text)
console.log(String(parsed.id)); // 7203371950759974900
console.log(parsed.id === 7203371950759974912); // true — but a lie!
That last line is the cruelest part: the literal 7203371950759974912 you typed is also truncated to the same double, so the comparison reports true. The corruption happened before any comparison ran.
The reliable detector is Number.isSafeInteger. Run it on every numeric ID you receive:
Number.isSafeInteger(parsed.id); // false → this value cannot be trusted
Number.isSafeInteger(42); // true
If isSafeInteger returns false for an ID field, the digits are already gone and you must change how you parse, not how you compare.
JavaScript fixes (and why a reviver does NOT work)
The instinct is to reach for the JSON.parse reviver — the second argument that lets you transform each value:
JSON.parse('{"id":7203371950759974912}', (key, value) => {
if (key === "id") return BigInt(value); // too late
return value;
});
// RangeError or a BigInt built from the ALREADY-corrupted 7203371950759974900
This cannot save you. By the time the reviver function runs, the engine has already tokenized the number text and produced a lossy Number. The reviver receives 7203371950759974900, not the original string 7203371950759974912. The damage is done one step upstream of the only hook you are given. There is genuinely no way to recover the lost digits with the built-in parser.
There are two real fixes.
Fix 1 — send IDs as strings. The cleanest solution is on the producer side: serialize large IDs as JSON strings. Twitter's API famously added id_str alongside id for exactly this reason.
{ "id": "7203371950759974912" }
A string preserves every digit perfectly and you compare IDs as strings, which is what you almost always wanted anyway.
Fix 2 — use a precision-preserving parser. When you cannot change the server, swap the parser. json-bigint parses large integers into BigInt, and lossless-json preserves numbers as a lossless string-backed type:
import JSONbig from "json-bigint";
const data = JSONbig.parse('{"id":7203371950759974912}');
console.log(data.id); // 7203371950759974912n (a BigInt — exact)
console.log(typeof data.id); // "bigint"
console.log(data.id.toString()); // "7203371950759974912"
Watch the serialization caveat: the native JSON.stringify cannot serialize a BigInt and throws.
JSON.stringify({ id: 7203371950759974912n });
// TypeError: Do not know how to serialize a BigInt
Use the same library's stringify, which knows to emit the BigInt as a bare number, or convert to a string yourself before stringifying:
JSONbig.stringify({ id: 7203371950759974912n });
// '{"id":7203371950759974912}' — round-trips exactly
Python: integers survive, floats do not
Python's int is arbitrary precision, so the headline integer problem mostly disappears. The standard library's json.loads parses a giant integer into a full-precision int by default:
import json
data = json.loads('{"id": 7203371950759974912}')
print(data["id"]) # 7203371950759974912 — exact, no loss
print(type(data["id"])) # <class 'int'>
Floats are a different story. JSON floats are still parsed into Python float, which is the same IEEE-754 double, so decimal precision is lost exactly as it is in JavaScript:
print(json.loads('{"x": 0.1}')["x"] + json.loads('{"y": 0.2}')["y"])
# 0.30000000000000004
For money and any value where decimal fidelity matters, parse floats into Decimal with the parse_float hook:
from decimal import Decimal
import json
data = json.loads('{"price": 19.99}', parse_float=Decimal)
print(data["price"]) # 19.99 — exact decimal
print(type(data["price"])) # <class 'decimal.Decimal'>
print(data["price"] * 3) # 59.97 — no drift
You can likewise pass parse_int to wrap integers in a custom type, though the default int is already lossless:
data = json.loads('{"id": 7203371950759974912}', parse_int=str)
print(data["id"]) # '7203371950759974912' — keep IDs as strings
Go: float64 by default is a trap
Go's encoding/json is the surprising one. When you unmarshal into an interface{}, every JSON number becomes a float64 — lossy for large integers, just like JavaScript:
var v map[string]interface{}
json.Unmarshal([]byte(`{"id": 7203371950759974912}`), &v)
fmt.Printf("%.0f\n", v["id"]) // 7203371950759974912 → 7203371950759975000
The fix is json.Decoder with UseNumber(), which yields a json.Number — a string under the hood that you convert explicitly:
dec := json.NewDecoder(strings.NewReader(`{"id": 7203371950759974912}`))
dec.UseNumber()
var v map[string]interface{}
dec.Decode(&v)
n := v["id"].(json.Number)
id, _ := n.Int64()
fmt.Println(id) // 7203371950759974912 — exact
If you control the struct, declare the field as json.Number directly so it never passes through float64:
type User struct {
ID json.Number `json:"id"`
}
var u User
json.Unmarshal([]byte(`{"id": 7203371950759974912}`), &u)
fmt.Println(u.ID) // 7203371950759974912
i, _ := u.ID.Int64() // convert when you need an integer
f, _ := u.ID.Float64() // or a float, if appropriate
For an ID that exceeds int64, keep it as the json.Number string and never convert.
Money: never store currency as a JSON float
The canonical demonstration of float imprecision needs no large integers at all:
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
Neither 0.1 nor 0.2 is exactly representable in binary floating point, so their sum drifts. Store a price like 19.99 as a JSON float and you have already accepted that it might come back as 19.989999999999998 after a few operations. For currency, there are two robust representations.
Integer minor units (cents). Store the value as an exact integer and divide only for display:
{ "amount": 1999, "currency": "USD" }
const cents = 1999;
const display = (cents / 100).toFixed(2); // "19.99"
A decimal string. Keep the value as a string in JSON and parse it with a decimal library on each side:
{ "amount": "19.99" }
Either way the rule is the same: the wire format must carry an exact value, because once a price has been a float/double, the original is unrecoverable.
Edge cases that bite at the boundaries
A few more failure modes round out the picture.
Exponent overflow to Infinity. A number too large for a double becomes Infinity on parse, and JSON.stringify then turns Infinity into null — so the value silently vanishes on the way back out:
JSON.parse("1e400"); // Infinity
JSON.stringify(Infinity); // "null"
JSON.stringify({ x: 1e400 }); // '{"x":null}'
Negative zero. JavaScript distinguishes -0 from 0, but JSON.stringify normalizes it to 0, so a sign you depended on disappears:
JSON.stringify(-0); // "0"
Object.is(JSON.parse("-0"), -0); // true on parse, but you lose it on the next stringify
Trailing-zero precision. JSON has no notion of significant trailing zeros. 1.10 and 1.100 both parse to the double 1.1, and re-serialize as 1.1. If trailing zeros are meaningful (a measurement, a version segment, a fixed-scale price), the number type cannot carry that information — use a string:
{ "version": "1.10", "weight": "1.100" }
The rule to remember
Treat JSON numbers as safe only for values that fit comfortably inside an IEEE-754 double: integers up to 2^53 − 1 and decimals where a tiny drift is acceptable. For anything outside that — database IDs, snowflakes, account numbers, currency, fixed-precision measurements — send it as a JSON string and parse it deliberately on each end. Validate incoming payloads with Number.isSafeInteger (or the equivalent in your language) on every numeric ID, and you will catch the silent truncation at the boundary instead of in a 404 three services downstream.
Related Tools & Resources
Tools
- JSON Validator — Paste an API response and confirm it parses cleanly before you hunt for precision bugs.
- JSON Formatter — Pretty-print payloads so a 19-digit ID is easy to read and compare against the raw text.
- JSON to TypeScript — Generate interfaces and decide where a numeric ID should really be typed as
string. - JSON to Go — Generate structs and swap risky
float64/intfields forjson.Number.
Learn More
- JSON Format Rules — How the JSON grammar defines numbers and why it allows unlimited precision.
- Working with JSON in Python — More on
json.loadshooks likeparse_floatandparse_int. - Mastering JSON in JavaScript — Deeper coverage of
JSON.parse, revivers, andJSON.stringify. - Parsing Large JSON Files — Streaming and decoder techniques where
UseNumber-style handling also matters.
Related Articles
Formatting JSON: When It Helps, When It Hurts
A practical guide to JSON beautifiers: pretty-print for humans, minify for transport, sort keys for diffs, and the lossy gotchas of round-tripping through stringify.
Common JSON Mistakes and How to Avoid Them
The 20 most common JSON syntax errors developers make, organized by category with examples and fixes.
How to Fix 'Unexpected End of JSON Input' Error
Learn what causes the 'Unexpected end of JSON input' error and how to fix it with practical solutions for JavaScript, Python, and other languages.
How to Open JSON Files: A Practical Guide
Open, view, and troubleshoot JSON files on macOS, Windows, and Linux with real commands for jq, Python, VS Code, and browsers — plus fixes for common errors.