JSON never specifies number precision, so a parser may read every number as an IEEE-754 double — and JavaScript does. Send a 64-bit ID (a tweet snowflake) through such a parser and it comes back changed by 21, no error. A typed Protobuf int64 round-trips it exactly.
JSON has numbers, but it never says how precise they are — and it doesn't separate integers from floats. So a JSON parser is free to read every number as an IEEE-754 double, and JavaScript's JSON.parse does exactly that. A double holds only 53 bits of integer precision, so any integer above 2⁵³ loses its low bits on the way in. You will send a perfectly ordinary 64-bit ID — a tweet snowflake — through such a parser and watch it come back changed by 21, no error raised. A typed encoding (Protobuf int64) round-trips the same ID exactly.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 5 — Encoding and Evolution, §"JSON, XML, and Binary Variants":
integers greater than 2⁵³ cannot be exactly represented in an IEEE 754 double-precision floating-point number, so such numbers become inaccurate when parsed in a language that uses floating-point numbers, such as JavaScript.
The book names the exact failure and the exact victim (X/Twitter, whose IDs are 64-bit). Here you reproduce it and see the off-by-21.
The surprise is about how numbers survive (or don't) a text format. These help; each line notes where it shows up.
int64, routinely above 2⁵³. This is the real-world blast radius.int64/long, so the decoder never guesses "double"; the integer is preserved by construction.id vs id_str. If only one is new, make it #1 — 53 bits of precision is the entire mechanism.
uv; Protocol Buffers via grpcio-tools + protobuf (compiled at runtime) — no Docker. One 64-bit ID: 1234567890123456789.
We take a 64-bit ID (1234567890123456789, above 2⁵³) and:
int(float(n))) to see where precision ends;JSON.parse, in Python via json.loads(..., parse_int=float);int64 field for contrast.The whole exercise is one script, code/json_integer_precision.py.
cd study/ddia/ch05/code
uv run --with grpcio-tools --with protobuf python3 json_integer_precision.py
Do not read the output yet — make each prediction first.
Predict. A double has 53 bits of integer precision. What happens to 2⁵³ and 2⁵³ + 1 when forced through a double?
2⁵³ survives; 2⁵³ + 1 cannot be represented and rounds down to 2⁵³ — the very first integer a double can't hold. Above this line, integers start collapsing onto their neighbors.
Predict. Send 1234567890123456789 as JSON and parse it back with a double-based parser (what a browser does). Does the ID come back unchanged?
Off by 21. The ID came back ...456768 instead of ...456789 — a different record, and no error anywhere. Note the parenthetical: Python's default parser happens to preserve it (Python uses big integers), but that's luck, not a guarantee — the same bytes fed to JavaScript's JSON.parse are mangled, and you rarely control the receiver's parser.
Predict. Encode the same ID in a Protobuf int64 field and decode it. Same problem, or exact?
Exact. Because the schema declares the field int64, the decoder never reaches for a double — the integer is preserved by construction. And the famous real-world workaround for JSON: X/Twitter returns every ID as both a number (id) and a string (id_str), because a string of digits sails through any JSON parser untouched.
2⁵³ + 1 rounds down to 2⁵³ through a double — the first unrepresentable integer.int64 round-trips the ID exactly; strings (id_str) are the JSON-side fix.A JSON number is just text like 1234567890123456789, and the format says nothing about how a reader should store it. JavaScript — where JSON was born — has exactly one number type, the IEEE-754 double, so JSON.parse turns that text into a double. A double devotes 52 bits to the mantissa, so it can represent every integer up to 2⁵³ and then only even ones for a while, then only multiples of 4, and so on: past 2⁵³ the representable integers thin out, and any value that isn't one of them snaps to the nearest that is. 1234567890123456789 isn't representable, so it lands on 1234567890123456768 — 21 away. Nothing throws; the parser did exactly what the spec allows.
The deeper point is that JSON pushes the integer/float and precision decisions onto the reader, so correctness depends on a parser you often don't own. Python's json preserves big integers, but that's an implementation choice, not a JSON guarantee — swap in a browser, a JavaScript service, or a library that parses numbers as floats, and the same bytes silently corrupt. A schema-based encoding removes the guessing: declaring a field int64 (Protobuf) or long (Avro) tells every decoder to keep 64 bits, so the value round-trips regardless of the language. Where you're stuck with JSON, you dodge the issue by not sending the number as a number at all — hence id_str, an ID encoded as a string.
{"id_str": "1234567890123456789"} and parse it back. Predict whether the string round-trips exactly through the double-based parser (it should) — the id_str fix in one line.time.time_ns() as a JSON number through the double-parser and predict how many nanoseconds of precision you lose.Sources: DDIA 2e, Ch. 5, §"JSON, XML, and Binary Variants" · IEEE-754 double precision · the X/Twitter API's id vs id_str.