jsonbigintjavascriptconceptsnumbers

Big Numbers in JSON — Why Large IDs Silently Break in JavaScript

·7 min read·JSON Concepts

A bug that doesn't throw an error

Precision loss in JSON numbers is one of the few JSON bugs that produces no error, no warning, and no stack trace — just a number that's silently *wrong*, usually discovered only when a lookup by ID mysteriously fails. It's worth understanding exactly why, because the fix is a convention, not a language feature.

Where the precision actually goes

The JSON spec places no limit on how large a number can be — {"id": 99999999999999999999} is perfectly valid JSON text. The problem is entirely on the *parsing* side: JavaScript's JSON.parse deserializes every JSON number into the number type, which is a 64-bit IEEE 754 double — the same type used for 3.14, -2, and everything else numeric in JS. A double can represent integers exactly only up to Number.MAX_SAFE_INTEGER2^53 - 1, or 9,007,199,254,740,991. Past that, adjacent integers start mapping to the *same* double.

javascript
JSON.parse('{"id": 9007199254740993}');
// => { id: 9007199254740992 }  — off by one, silently

Note this isn't unique to JSON.parse9007199254740993 typed directly as a JS numeric literal has exactly the same problem, since it's the number type itself that can't represent it, not the JSON parser specifically. But it hits JSON far more often in practice, because JSON numbers routinely come from systems that *don't* have this limitation — Postgres bigint, Java long, Go int64, Twitter/X-style Snowflake IDs — all of which comfortably exceed 2^53 as a matter of routine, not an edge case.

Where this actually bites

64-bit database primary keys. A Postgres bigint column, or any auto-incrementing ID on a busy table, will eventually exceed 2^53. An API that serializes that ID as a bare JSON number will corrupt it for every JavaScript client the moment it crosses that threshold — and because it happens silently, the bug often ships and sits unnoticed until IDs get large enough, which can be months or years into a table's life.

Snowflake-style distributed IDs. IDs generated by a timestamp+machine-id+sequence scheme (Twitter/X's original Snowflake, Discord's, Instagram's) are 64-bit integers by design and are typically already well past 2^53 on day one — this is precisely why X's API returns both an id (numeric, truncated for legacy compatibility) and an id_str (string, exact) for every object.

Financial and scientific data. Any value where losing the last few digits of precision is a correctness problem, not just a cosmetic one — pass it as a string, or scale it into an integer-safe range (e.g., store cents instead of dollars, and even then, watch the upper bound).

The fix: strings, not bare numbers

The universal convention is to serialize anything that might exceed 2^53 as a JSON string, not a bare number:

json
{
  "id": "9223372036854775807",
  "id_numeric_truncated": 9223372036854776000
}

A string is exact — JSON strings have no numeric precision limit, so any 64-bit (or larger) integer round-trips perfectly as text. The tradeoff is that the consumer has to know to treat that field as an opaque identifier, not something to do arithmetic on directly — which is almost always the correct mental model for an ID anyway.

Handling it if you can't change the API

If you're stuck consuming an API that returns large numbers as bare JSON numbers (already lossy on the wire, nothing you can do after the fact), JavaScript's native BigInt can at least prevent *further* precision loss during your own processing — but only if you avoid JSON.parse's default number handling entirely, since the damage from the original serialization already happened before your code ever runs:

javascript
// JSON.parse's optional "reviver" can intercept and reparse big numbers as BigInt —
// but only if the source JSON already preserved the value as a string.
const data = JSON.parse(text, (key, value) => {
  if (typeof value === "string" && /^\d{16,}$/.test(value)) {
    return BigInt(value);
  }
  return value;
});

This only works if the *source* JSON encoded the number as a string in the first place — if the sender already serialized it as a bare number, the precision is already gone by the time your parser sees it, and no amount of clever parsing on your end recovers it. There's no way around fixing this except at the source.

Frequently asked questions

It's specific to how each language's JSON parser maps JSON numbers onto its own number types. Python's json module parses integers into arbitrary-precision int by default, so this specific bug doesn't occur there. Java, Go, and C# all have distinct integer types (long/int64) that a JSON library can deserialize into correctly, if the field is typed that way — the JavaScript case is unusual specifically because JS has historically had only one general-purpose numeric type.

No — JSON.parse doesn't support a "parse all integers as BigInt" mode natively (as of this writing), and JSON.stringify throws a TypeError if you try to serialize a BigInt directly (Do not know how to serialize a BigInt) — you have to convert it to a string yourself first with .toString().

Compare a known-large ID field's value against the same ID fetched as plain text (e.g., via curl piped to a text viewer rather than a tool that reparses it as JS numbers) — if the numeric JSON value and the raw text differ, you've found it. JSONKit's JSON Formatter preserves numbers as literal text rather than reparsing them into JS numbers internally for exactly this class of gotcha, but any code you write that calls JSON.parse on that same payload will still hit the underlying JavaScript limitation.

No — they're unrelated. A 32-bit integer overflows around ~2.1 billion, a much smaller number that shows up in older systems and image/file format fields. Number.MAX_SAFE_INTEGER (2^53 - 1, about 9 quadrillion) is specific to JavaScript's double-precision float and is a completely different, much higher ceiling.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.