Big Integers in JSON and the 2^53 Limit

An order ID ends in 993 in the database and 992 in the browser. Nothing is wrong with the JSON — the digits are right there — but most JavaScript code reads every number as a 64-bit float, and floats run out of integer precision at 2^53.

What actually happens

JSON’s grammar puts no limit on the size or precision of a number. The limit comes from the parser. JavaScript has one number type, the IEEE 754 double, which stores a 53-bit significand. Every integer up to 2^53 = 9,007,199,254,740,992 can be represented exactly; above that, only every second integer can, then every fourth, and so on.

JSON.parse('9007199254740993')        // 9007199254740992
JSON.parse('12345678901234567890')    // 12345678901234567000
Number.MAX_SAFE_INTEGER               // 9007199254740991 (2^53 - 1)

No error is raised. The parser rounds to the nearest representable double and carries on, so the corruption is silent. If the value is written back — a PATCH request, a cache, a log line — the wrong ID is now stored permanently.

The same rounding affects decimals and exponents. JSON.parse('1.10') gives 1.1, losing a trailing zero that might have been meaningful in a price, and JSON.parse('1E400') gives Infinity.

Who is affected

Any system that maps JSON numbers to doubles by default:

  • JavaScript and TypeScript: JSON.parse, response.json(), and every tool built on them — including browser DevTools’ JSON preview and many online formatters, which parse and re-serialise the document.
  • Go: decoding into interface{} or any produces float64. Decoding into an int64 field is exact, and Decoder.UseNumber() keeps the original text as json.Number.
  • Java, C# and Rust: exact when you deserialise into typed long, decimal, BigInteger or u64 fields; Jackson’s tree model has USE_BIG_INTEGER_FOR_INTS and USE_BIG_DECIMAL_FOR_FLOATS for untyped data.
  • Python: json.loads reads integers with arbitrary precision, so IDs are safe; floats are doubles unless you pass parse_float=decimal.Decimal.
  • Databases: PostgreSQL’s jsonb stores numbers as arbitrary-precision numeric; other stores vary.

64-bit identifiers are the usual victims: Twitter/X snowflake IDs (the reason its API added an id_str field next to id), Discord IDs, database BIGINT keys, and nanosecond timestamps.

What the standards say

RFC 8259 allows implementations to limit the range and precision of numbers, and notes that interoperability is best for integers within ±(2^53 − 1) — the range a double handles exactly. RFC 7493 (I-JSON), a profile for internet protocols, goes further and recommends encoding larger integers and high-precision decimals as strings. In other words, the format permits big numbers, but a sender who wants every receiver to agree should not send them as numbers.

Fixes on the producer side

The most robust fix is to send big identifiers as strings: "id": "9007199254740993". IDs are labels, not quantities — nobody adds them — so a string loses nothing, and every parser in every language reads it exactly. Many large APIs do this for all 64-bit IDs, and a JSON Schema can document it with "type": "string", "pattern": "^[0-9]+$".

For amounts of money, send integer minor units (cents) when they fit comfortably within 2^53, or decimal strings such as "129.90" when precision and trailing zeros matter.

If you serialise with JavaScript, note that JSON.stringify refuses BigInt values with TypeError: Do not know how to serialize a BigInt. Convert them explicitly with a replacer, or emit raw digits with JSON.rawJSON where the runtime supports it.

Fixes on the consumer side

When you cannot change the API, parse without losing digits:

// Modern engines: read the original source text of each number
const data = JSON.parse(text, (key, value, ctx) =>
  typeof value === 'number' && !Number.isSafeInteger(value) && /^-?\d+$/.test(ctx?.source ?? '')
    ? BigInt(ctx.source)
    : value,
);

The third context argument to the reviver, with its source property, comes from the “JSON.parse source text access” proposal and is available in recent Chrome, Edge, Firefox and Node.js releases; check your targets before relying on it. Elsewhere, use a library: lossless-json keeps numbers as LosslessNumber objects you can convert as needed, and json-bigint returns BigInt or BigNumber values. On the command line, jq 1.7 and later keep the precision of integer literals it does not modify, while older versions round them.

Whatever you choose, apply it at the boundary — the first place the JSON is parsed. Once a value has been through JSON.parse, the lost digits cannot be recovered.

Timestamps are big integers too

IDs are not the only values that cross the line. Seconds and milliseconds since 1970 are far below 2^53, but finer units are not:

  • milliseconds today are around 1.8 × 10^12 — safe
  • microseconds are around 1.8 × 10^15 — still safe, but within a factor of five of the limit
  • nanoseconds are around 1.8 × 10^18 — far beyond it, so the last few digits are lost

Go’s time.Time.UnixNano(), OpenTelemetry trace timestamps and many database exports use nanoseconds. Parsed with JSON.parse, two events a few hundred nanoseconds apart can end up with identical timestamps, which reorders or merges them in a timeline. Send such values as strings, or as ISO 8601 text with fractional seconds.

A quick test for your own stack: send 9007199254740993 through every hop — API, queue, cache, front end — and compare the value that comes out. If it ends in 992 anywhere, that hop parses numbers as doubles.

How PasteKit handles big numbers

The PasteKit JSON formatter uses a lossless parser and printer: the digits of every number are carried through as text, so 9007199254740993 stays 9007199254740993, 1.10 keeps its trailing zero and 1E400 is printed as written instead of becoming Infinity. Minifying and sorting keys keep every number exactly as written too, and the conversions to YAML, CSV and JSON Lines carry large integers through digit for digit, so you can inspect or reshape a payload without corrupting its IDs. To check whether an API is affected, paste a response here and compare it with what your application logs after parsing.

Frequently asked questions

What is the largest integer JSON.parse reads exactly?

9007199254740991, which is Number.MAX_SAFE_INTEGER (2^53 − 1). 2^53 itself is exact too, but beyond it not every integer can be represented.

Does JSON itself limit number size?

No. The grammar allows any number of digits. Limits come from parsers, and RFC 8259 warns that numbers beyond double precision may not interoperate.

Should I use BigInt for IDs in JavaScript?

Only if you must do arithmetic on them. For identifiers, strings are simpler: they serialise without special handling and compare correctly.

Does PasteKit round large numbers?

No. Its JSON parser keeps the original digits of every number, so formatting and minifying never change a value, and conversions to YAML, CSV and JSON Lines keep large integers exact.

Why does my browser DevTools show a different number from the raw response?

The preview pane parses the body with JSON.parse, which rounds numbers above 2^53. The raw Response tab shows the digits the server actually sent.

Related