Skip to content
Back to Blog
By JSONConvert Team··6 min read

JSON Large Numbers: Why 64-Bit IDs Break

Paste {"id": 7203541266478103041} into a JavaScript console, parse it, and print it back. You get 7203541266478104000. Nothing threw and nothing warned, but the last four digits of a database primary key are gone. This is JSON large number precision loss, and it hits every application that moves 64-bit IDs across an API boundary.

The 2^53 Ceiling

RFC 8259 sets no limit on how big a JSON number can be. It only notes that implementations vary, and that you get the best interoperability by staying inside IEEE-754 double precision. JavaScript treats that as a hard rule: every number JSON.parse produces is a double, and a double carries 53 bits of mantissa.

That gives you exact integers up to Number.MAX_SAFE_INTEGER, which is 9007199254740991. Past it, the representable values start skipping:

A signed 64-bit integer runs to 9223372036854775807, roughly a thousand times past that ceiling. So any system handing out int64 identifiers is emitting values JavaScript cannot hold:

SourceExample IDSurvives JSON.parse?
Discord snowflake7203541266478103041No
Twitter/X snowflake1786396200000000000No
Postgres bigserial9007199254740993Only while below 2^53
Stripe object ID"cus_NffrFeUfNV2Hib"Yes, it is a string
MongoDB ObjectId"507f1f77bcf86cd799439011"Yes, hex string
Notice which APIs never have this problem. Stripe and MongoDB decided long ago that an identifier is an opaque string rather than an arithmetic value, and the entire class of bug disappeared.

What the Generators Emit

Here is the uncomfortable part. Run a Discord-shaped payload through the converters on this site:

The JSON to TypeScript converter gives you this:

That type is wrong, and so is the identical output from every other JSON-to-TypeScript tool, including the paid ones. number claims the value round-trips. It does not. The honest annotation is string if you fix the transport or bigint if you fix the parser, and no tool can tell which one you meant from a single sample.

It goes further than a bad annotation. The converter runs JSON.parse in your browser before it infers anything, so by the time inference sees the payload the ID has already collapsed to 7203541266478104000. The tool cannot warn you about digits it never received.

The other targets come out fine, for reasons that have nothing to do with the generator. JSON to Go emits int, which is 64 bits on any platform you will deploy to, and encoding/json decodes the literal straight into it with no float in the middle. JSON to Pydantic emits int, and Python integers are arbitrary precision. Both are correct by accident of their runtime. JavaScript is the outlier, and TypeScript inherits the problem.

Send It as a String

This is the only fix that works everywhere. "id": "7203541266478103041" parses identically in every language, and you give up nothing, because you were never going to do arithmetic on a primary key. If you own the producer, do this and skip the rest of this post.

Twitter hit this in 2011 and started shipping both id and id_str on every object. The duplication looked clumsy and it was still the right call.

Parse Into BigInt

When you consume someone else's API you have to intercept the number before it becomes a double. Node 22 and current browsers support source text access in the reviver, which hands you the original characters:

Then hand-edit the generated interface to id: bigint. Two things to plan for. JSON.stringify throws TypeError: Do not know how to serialize a BigInt on the way back out, so you need a replacer that converts to a string. And bigint will not mix with number in arithmetic without an explicit cast, which is a feature: it forces you to look at every place the ID gets used as a number.

On older runtimes, the json-bigint package does its own tokenizing and gets the same result without reviver context.

The Same Bug in Other Languages

Java is the one that surprises people. Jackson binding into Map picks the narrowest type that fits, so an int64 arrives as Long and survives, but bind the same field into a double or let a loosely typed layer widen it and you are back to 53 bits. Declare the field long explicitly.

Go's encoding/json has the same trap: decode into interface{} and every number becomes float64. Use a struct with an int64 field, or json.Decoder with UseNumber() so values arrive as json.Number and you decide.

C# and Python are safe by default. System.Text.Json binds to long without a float intermediate, and Python's json module produces a native int of any size.

Spotting It in a Payload You Did Not Write

Fetch the response with curl and look at the raw bytes, then paste the same response into the JSON viewer. If a numeric field renders with a different digit sequence than what came over the wire, that field is above 2^53 and you have the bug. Trailing zeros where the raw response had varied digits is the signature.

A cheaper heuristic: treat any integer of 16 digits or more as suspect, and anything with 19 digits as an int64 that will definitely break.

The reason this reaches production so often is that it passes every test you wrote. Fixtures use "id": 1. Staging seeds from an autoincrement starting at 1. The failure needs a real 19-digit identifier, which only the real system hands out, and by then the mangled ID is sitting in a foreign key column. If you generate types from sample payloads, sample from production.

Related Tools