viewer.csslab.dev

Why an ID should be a string in JSON

A string. JSON has one numeric type, and nearly every parser reads it into a double-precision float, which represents integers exactly only up to 2^53 − 1 — about 9.007 × 10^15. A 64-bit database ID can exceed that, and when it does the parser silently returns the nearest representable value instead. The record is then fetched, compared, or deleted by an identifier that is off by one, with no error anywhere in the chain.

What actually goes wrong

JSON.parse("9007199254740993") returns 9007199254740992. The value is one less than what was sent, and nothing in the process reports a problem: the JSON was valid, the parse succeeded, and the number is a perfectly ordinary number. It is simply not the number that was serialised.

That threshold — 9,007,199,254,740,991 — is Number.MAX_SAFE_INTEGER. Below it, every integer round-trips exactly. Above it, the gaps between representable values grow, and two distinct IDs can collapse onto the same double. Snowflake IDs, Twitter status IDs, Kafka offsets, and any bigint primary key are routinely above it.

The failure is quiet by construction. There is no exception to catch and no flag to check, because from the parser's point of view nothing unusual happened. The first visible symptom is usually a record that cannot be found, or worse, the wrong record being returned.

Why the specification allows it

RFC 8259 does not put a limit on the size of a JSON number — the grammar accepts any sequence of digits. What it does instead is warn that interoperability is only assured within the range that IEEE 754 double precision represents exactly, because that is what implementations actually use.

So a serialiser that writes a 64-bit integer as a JSON number is producing valid JSON. The document is correct; the round trip is not. The specification is telling you that correctness of the document and correctness of the value are two different questions.

What to do instead

Serialise the identifier as a JSON string. A string round-trips exactly at any length, sorts predictably, and costs two extra bytes. Every language that consumes the API already has a string type that will not quietly change the value.

This is what the APIs that ran into the problem now do. Twitter added id_str alongside id after the numeric field broke JavaScript clients; Stripe, Discord, and most systems built afterwards send identifiers as strings from the start.

If you are on the receiving end and cannot change the producer, parse with something that preserves the value — a reviver that reads large integers as BigInt, or a parser configured to return numbers as strings — before the double conversion happens. Once JSON.parse has run, the original value is gone and cannot be recovered.

The same reasoning applies to anything that is an identifier rather than a quantity: account numbers, order references, and zero-padded codes. If you will never do arithmetic on it, it is not a number.

Tools for this

Specifications this follows

Updated 2026-09-05