Handling Large Integers and 64-bit IDs in JSON
Symptom: an API returns order 7205759403792794123, the browser looks it up, and the server answers 404 for 7205759403792794000. No error was logged and no exception thrown; three digits changed between the response body and the parsed object. This guide is part of Data Format and Precision Contracts.
Root Cause: JSON Numbers Become Doubles
The JSON grammar allows arbitrary-precision numbers. Almost no parser preserves that. JavaScript’s JSON.parse produces a Number, an IEEE 754 double, which represents integers exactly only up to 2⁵³ − 1.
Number.MAX_SAFE_INTEGER // 9007199254740991
JSON.parse('{"id":7205759403792794123}').id // 7205759403792794000
9007199254740993 === 9007199254740992 // true ← two integers, one value
The loss is silent by design: the parser is not doing anything wrong, it is producing the nearest representable double, exactly as the language specifies.
Note that format: int64 does not help. It is an annotation for code generators, asserts nothing by default, and changes nothing about how a JavaScript parser reads the number.
Step 1: Model the Identifier as a String
components:
schemas:
Order:
type: object
required: [id, customer_id, created_at]
properties:
id:
type: string # NOT integer, NOT format: int64
pattern: '^[0-9]{1,20}$'
description: |
A 64-bit identifier serialised as a decimal string. It is numeric in
our database and a string on the wire, because IEEE 754 doubles —
what every JavaScript JSON parser produces — cannot hold values above
2^53 exactly. Treat it as opaque: do not parse, sort or compare it
numerically.
example: "7205759403792794123"
customer_id:
type: string
pattern: '^[0-9]{1,20}$'
example: "7205759403792781004"
Why the description says “opaque”: the moment a consumer writes Number(order.id) to sort a list, the bug returns on their side. Saying it in the contract is the only place that instruction reaches everyone.
The pattern matters too. Without it, "7205759403792794123 " with a trailing space, or "7.2e18", are valid strings and will not match anything in your database.
Step 2: Keep Them Opaque in Client Code
// GOOD — the identifier stays a string end to end.
const order = await client.GET("/orders/{id}", { params: { path: { id } } });
const isSame = order.data!.id === expectedId; // string comparison
// BAD — every one of these reintroduces the loss.
Number(order.data!.id) // precision gone
parseInt(order.data!.id, 10) // precision gone
orders.sort((a, b) => Number(a.id) - Number(b.id)) // wrong order, silently
JSON.parse(raw).id // gone before you see it
Sorting is the subtle one. If chronological ordering matters, sort on a timestamp field, not on a snowflake identifier that merely happens to be time-ordered.
Where you genuinely must compare magnitudes, BigInt works in application code — just never on the wire:
// Comparing two string ids by magnitude, without going through Number.
const byId = (a: string, b: string) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0);
Step 3: Migrate a Published Numeric Id Safely
Changing id from integer to string in place is a breaking change: strict consumers reject the payload, and lenient ones start comparing a string against a number. Run it through the deprecation window described in API versioning and deprecation policies.
# Phase 1 — both fields, both populated. Additive, so no version bump.
Order:
type: object
required: [id, id_string]
properties:
id:
type: integer
format: int64
deprecated: true
description: |
Deprecated — loses precision in JavaScript above 2^53. Use `id_string`.
Removed in v3 (2027-03-01).
id_string:
type: string
pattern: '^[0-9]{1,20}$'
description: The same identifier, exact. Treat as opaque.
# Phase 2 — v3 drops the numeric field and renames the string one back to `id`.
Order:
type: object
required: [id]
properties:
id:
type: string
pattern: '^[0-9]{1,20}$'
Step 4: Check the Whole Path, Not Just the API
The API can be correct and the value still lost — at a gateway that reserialises, in a log pipeline, or in an analytics event.
# What the API actually sent, before any parsing.
curl -s https://api.example.com/v2/orders/7205759403792794123 | grep -o '"id":"[0-9]*"'
# "id":"7205759403792794123"
# What a naive proxy does to it.
curl -s https://api.example.com/v2/orders/7205759403792794123 \
| jq '.id_numeric_for_comparison = (.id | tonumber)' | jq -r '.id, .id_numeric_for_comparison'
# 7205759403792794123
# 7205759403792794000 ← jq's tonumber has the same limit
That second command is worth running against any tool in the path. jq, most log shippers, and a good many gateways use double-precision numbers internally.
Verification
test("a 64-bit identifier round-trips exactly as a string", () => {
const id = "7205759403792794123";
expect(JSON.parse(JSON.stringify({ id })).id).toBe(id);
});
test("the same value as a number does not round-trip", () => {
// The regression this whole page exists to prevent.
const raw = '{"id":7205759403792794123}';
expect(String(JSON.parse(raw).id)).not.toBe("7205759403792794123");
});
test("the schema rejects a non-numeric or padded string", () => {
expect(orderId.safeParse("7205759403792794123").success).toBe(true);
expect(orderId.safeParse("7205759403792794123 ").success).toBe(false);
expect(orderId.safeParse("7.2e18").success).toBe(false);
});
test("ids sort correctly by magnitude when compared as BigInt", () => {
const ids = ["7205759403792794123", "7205759403792794000", "999"];
expect([...ids].sort(byId)).toEqual(["999", "7205759403792794000", "7205759403792794123"]);
// and the naive comparison gets it wrong:
expect([...ids].sort()).not.toEqual(["999", "7205759403792794000", "7205759403792794123"]);
});
The last test also demonstrates why plain lexicographic sorting is not a substitute: "999" sorts after "7205…" as a string.
Edge Cases and Caveats
- Generated types say
number. A schema declaringtype: integer, format: int64generatesnumberin TypeScript, which compiles and loses data. Changing the schema tostringis what fixes the generated type — see generating TypeScript types with openapi-typescript. - Query parameters and path segments. These are already strings on the wire, so a numeric path parameter is safe in transit and unsafe the moment application code parses it into a number to look it up.
- Other affected values. Nanosecond timestamps, byte counts on large storage, cumulative counters and low-denomination currency totals can all cross 2⁵³.
- Databases and drivers. Some drivers return
BIGINTcolumns as JavaScript numbers by default, losing precision before the value reaches your serialiser. Configure the driver to return strings or BigInt, and test it — this is a common place for the fix to be undone. - Custom JSON parsers. Libraries exist that parse large integers into BigInt, and they solve the client side. They do not help consumers you do not control, which is why the wire format has to be a string.
Preventing the Problem in New APIs
The migration above is avoidable entirely, and the cost of avoiding it is one decision made early: identifiers are strings on the wire, always, regardless of how they are stored.
The rule is worth applying even when the current identifier is a 32-bit integer that could not possibly overflow. Identifier schemes change — a service migrating from a database sequence to a distributed identifier generator is routine — and a scheme change that would otherwise be invisible to consumers becomes a breaking change if the wire type has to move from number to string at the same time.
There is a second benefit that shows up later. A string identifier can absorb a prefix — ord_, cus_ — which makes an identifier self-describing in logs and error messages, and makes it obvious when the wrong kind of identifier is passed to an endpoint. That is a small ergonomic win that costs nothing at the point where the decision is made and cannot be retrofitted cheaply.
The one genuine cost is storage and index size on the consumer side, if they persist your identifiers. It is real and it is small, and it is the right trade against a class of silent data corruption.
Checking a Codebase for the Pattern
A quick search finds most instances before they bite: look for Number(, parseInt( and + applied to anything named id, and for schemas declaring type: integer alongside a property name ending in _id. Each hit is either a deliberate decision or a latent corruption, and there are usually few enough to check by hand in an afternoon.
One Sentence to Take Away
If an integer can ever exceed nine quadrillion, it travels as a string — and identifiers travel as strings regardless, because identifier schemes change more often than wire types can.
Frequently Asked Questions
What exactly is the limit?
Number.MAX_SAFE_INTEGER, which is 2⁵³ − 1 — 9007199254740991. Integers above it are not all representable as doubles, so parsing rounds to the nearest one that is, with no warning.
Can I use BigInt instead of a string?
In your own code, yes. On the wire, no: JSON has no BigInt literal, JSON.stringify throws on one, and JSON.parse produces a Number regardless. A string is the only representation every JSON parser preserves exactly.
Does format int64 protect me?
No. format is an annotation and asserts nothing by default, and even when asserted it does not change how the JavaScript parser reads the number. It documents intent for code generators; it does not make the value survive.
How do I migrate an id that already ships as a number?
Add a string field alongside it, populate both for a full deprecation window, move consumers, then remove the numeric one under your versioning policy. Changing the type in place is a breaking change with no warning.
Are there other values with the same problem?
Yes — anything that can exceed 2⁵³: byte counts on large storage, nanosecond timestamps, cumulative counters, and financial amounts in the smallest unit of a low-value currency.