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:
| Source | Example ID | Survives JSON.parse? |
|---|---|---|
| Discord snowflake | 7203541266478103041 | No |
| Twitter/X snowflake | 1786396200000000000 | No |
Postgres bigserial | 9007199254740993 | Only while below 2^53 |
| Stripe object ID | "cus_NffrFeUfNV2Hib" | Yes, it is a string |
| MongoDB ObjectId | "507f1f77bcf86cd799439011" | Yes, hex string |
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.