JSON BigInt Strategies: Sending 64-bit Integer IDs Without JavaScript Precision Loss
Last updated:
This is a hands-on guide focused on one specific failure mode: a 64-bit integer identifier survives every layer of your stack — Postgres bigint, a Python service, a Java microservice, a Go gateway — and then silently rounds the moment a JavaScript client calls JSON.parse. The JSON number precision parent guide covers the IEEE-754 theory and decimal precision for financial values; this page is language-by-language workarounds for 64-bit IDs specifically. You will see five concrete strategies (string wire format, the json-bigint library, custom revivers, server-side coercion in Python and Java, driver-level fixes in Postgres and MySQL), how Twitter, Discord, and Stripe each chose a different path, and the integration test that catches the bug before it ships. The root cause is the same in every case — Number.MAX_SAFE_INTEGER = 2^53 - 1 — but the fix depends on where you can intervene. Pick the strategy that matches your existing API contract — adding string IDs to an existing numeric field is the most expensive option, so the cheaper time to decide is before the first user.
Inspecting an API response and not sure whether the ID field is a number or a string? Paste it into Jsonic's JSON Validator — type-aware highlighting shows numeric vs string values at a glance, and large integers are flagged when they exceed 2^53.
The 2^53 boundary: why MAX_SAFE_INTEGER matters for IDs
JavaScript stores every Number as an IEEE-754 64-bit double. A double has 52 explicit mantissa bits plus an implicit leading 1, giving 53 bits of integer precision. The largest integer it can represent exactly is 2^53 - 1 = 9,007,199,254,740,991 — exposed as Number.MAX_SAFE_INTEGER. Above this boundary, multiple consecutive integers collapse onto the same double value, and arithmetic loses its inverse:9007199254740993 - 9007199254740992 === 0 in a default browser console.
// Live demonstration of the precision loss
const raw = '{ "id": 9007199254740993, "name": "alice" }'
const parsed = JSON.parse(raw)
console.log(parsed.id) // 9007199254740992 ← off by one
console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991
console.log(Number.isSafeInteger(parsed.id)) // false
// Round-trip persists the corrupted value — no error
JSON.stringify(parsed)
// '{"id":9007199254740992,"name":"alice"}'The danger is that this is silent. There is no exception, no warning, no console message. A client that fetches a user record and stores user.id in local state has now created a phantom user — the database row at 9007199254740993 is unreachable through that client. The only detection is an integration test, and the only fix is to keep the value out of theNumber type. Every strategy below is some flavor of that fix: either the value never becomes a Numberon the wire (it's a string), or the parser builds a BigInt directly instead of a Number.
Strategy 1: send big numbers as strings (recommended for new APIs)
The cheapest, most portable strategy is to never put the value in a JSON number at all. Ship it as a JSON string. "id": "9007199254740993" parses identically in JavaScript, Python, Java, Rust, and Go — no rounding, no special parser, no per-field configuration. The downside is small: callers cannot do arithmetic without an explicit BigInt(id) or parseInt conversion, which is almost always fine because IDs are opaque tokens, not quantities you add and subtract.
// Wire format — every large integer is a quoted string
{ "id": "9007199254740993", "parent_id": "9007199254740992" }
// TypeScript client — branded string for compile-time safety
type UserId = string & { readonly __brand: 'UserId' }
function asUserId(s: string): UserId {
if (!/^[0-9]+$/.test(s)) throw new Error('invalid UserId')
return s as UserId
}
const user = JSON.parse(responseText) as { id: UserId }
// user.id is exact ('9007199254740993') and cannot be assigned to OrderIdBranded string types in TypeScript give you the same compile-time safety as a dedicated numeric ID type without any runtime cost. UserId and OrderId are both string at runtime but cannot be assigned to each other in your code — typos that mix identifiers fail at edit time. See TypeScript parsing with Zod for schema-validated string IDs with a one-line type definition.
When this strategy fails: when you cannot change the API contract and existing clients depend on the value being a JSON number. The next two strategies are for that case.
Strategy 2: json-bigint library (Node.js) for transparent BigInt
json-bigint is an npm package that wraps the JSON parser and emits native BigInt for any integer exceeding Number.MAX_SAFE_INTEGER. Smaller integers still come back as Number for compatibility. It also provides a matching stringify so values round-trip without manual toString() calls. Install it once and swap JSON.parse on the specific fetch path that returns large integers.
// npm install json-bigint
import JSONbig from 'json-bigint'
const parser = JSONbig({
useNativeBigInt: true, // opt into BigInt (default is bignumber.js)
alwaysParseAsBig: false, // small ints still come back as Number
})
const parsed = parser.parse('{ "id": 9007199254740993, "qty": 12 }')
console.log(typeof parsed.id, parsed.id) // 'bigint' 9007199254740993n
console.log(typeof parsed.qty, parsed.qty) // 'number' 12
// stringify also handles BigInt — vanilla JSON.stringify cannot
parser.stringify(parsed) // '{"id":9007199254740993,"qty":12}'Per-endpoint, not global: avoid the temptation to monkey-patch JSON.parse at app startup. Many libraries (state management, RPC clients, validators) assume number-shaped values everywhere; flipping the default causes hard-to-debug failures in unrelated code. Restrict json-bigint to the fetch path that actually needs it.
// fetch wrapper that uses json-bigint only for /api/payments responses
async function fetchPayment(id: string) {
const res = await fetch(`/api/payments/${id}`)
const text = await res.text() // raw body — do not call res.json()
return parser.parse(text) // BigInt-aware parse
}Caveats: BigInt values are not assignable to Number parameters, do not work with most chart libraries, and require an explicit replacer for plain JSON.stringify. If those constraints bite, fall back to Strategy 1 (strings) or Strategy 3 (selective revival).
Strategy 3: custom reviver/replacer + BigInt support
JSON.parse accepts a second argument — a reviver function called for every key/value pair as the tree is built. The reviver lets you map specific field names to BigInt without pulling in a third-party parser. The downside compared to json-bigint is that the reviver runs on the already-parsed Number value, so for integers above 2^53 the precision is already lost. The fix is to keep those values as strings on the wire and convert in the reviver — which makes this strategy a hybrid of Strategies 1 and 2.
// Server ships large integers as JSON strings:
// '{ "id": "9007199254740993", "qty": 12 }'
const BIG_FIELDS = new Set(['id', 'parent_id', 'order_id'])
const reviver = (key: string, value: unknown) =>
BIG_FIELDS.has(key) && typeof value === 'string' && /^-?[0-9]+$/.test(value)
? BigInt(value)
: value
const parsed = JSON.parse(raw, reviver) as { id: bigint; qty: number }
parsed.id === 9007199254740993n // true — exact
// Matching replacer for round-trip
const replacer = (_k: string, v: unknown) =>
typeof v === 'bigint' ? v.toString() : v
JSON.stringify(parsed, replacer) // '{"id":"9007199254740993","qty":12}'This pattern gives you exact BigInt semantics on the client without changing the vanilla parser. The reviver/replacer pair lives next to the type definition, so the conversion contract is local and reviewable. For schema-validated parsing with the same shape, see TypeScript parsing with Zod — Zod has a z.coerce.bigint() that combines the type check and the conversion in one step.
Server-side: Python int (arbitrary precision) — easy; Java long → must serialize as String
The server side of the equation is uneven across languages. Python is the easiest: int is arbitrary precision, so json.loads and json.dumps handle any integer losslessly with no configuration. Java is the trickiest: long is a fixed 64-bit type whose maximum value (2^63 - 1 = 9,223,372,036,854,775,807) exceeds JavaScript's safe range by orders of magnitude. If you let Jackson serialize a Java Long as a JSON number, the value goes onto the wire correctly but JavaScript clients round it.
# Python — no special handling needed
import json
raw = '{ "id": 9007199254740993, "balance": 123456789012345678 }'
data = json.loads(raw)
print(data['id']) # 9007199254740993 — exact, arbitrary precision int
print(data['balance']) # 123456789012345678 — also exact
# Round-trip: json.dumps writes exact integers, no rounding
print(json.dumps(data))// Java with Jackson — annotate Long fields to serialize as String
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
public class User {
@JsonSerialize(using = ToStringSerializer.class)
private Long id; // serialized as "1234567890123456789"
private Integer qty; // small enough — serialized as number
}
// Or apply to every Long via a module:
SimpleModule m = new SimpleModule();
m.addSerializer(Long.class, ToStringSerializer.instance);
m.addSerializer(Long.TYPE, ToStringSerializer.instance);
new ObjectMapper().registerModule(m);For Kotlin and Scala the story matches Java — both use the JVM Long and benefit from the same Jackson configuration. Go is in between: the standard encoding/json package emits int64 as a JSON number, butjson:"id,string"on the struct tag tells the encoder to wrap the field in quotes. Rust's serde_json behaves like Go — number by default, with #[serde(with = "serde_with::DisplayFromStr")] to opt into string serialization per field.
PostgreSQL bigint, MySQL BIGINT UNSIGNED — text-coercion strategies
The database driver is where the precision loss often originates. Postgres bigint is a signed 64-bit type (max 2^63 - 1); MySQL BIGINT UNSIGNED goes up to 2^64 - 1 = roughly 1.8 × 10^19. Both ranges exceed JavaScript's safe boundary. The fix is at the driver layer: tell the driver to return the value as a string (or as a BigInt) and pass that string straight into the JSON response.
// node-postgres (pg) — BIGINT is OID 20
import pg from 'pg'
// Default already returns string. Make it explicit so tutorials that
// override with parseInt cannot creep in:
pg.types.setTypeParser(20, (val) => val) // string (safe)
// pg.types.setTypeParser(20, (val) => BigInt(val)) // BigInt for arithmetic
const { rows } = await client.query('SELECT id, name FROM users WHERE id = $1', [userId])
return Response.json({ id: rows[0].id, name: rows[0].name })// mysql2 — bigNumberStrings for safe defaults
const pool = mysql.createPool({
host: 'localhost', user: 'app', database: 'shop',
supportBigNumbers: true, // allow BIGINT values larger than 2^53
bigNumberStrings: true, // return them as strings (not Number)
})
const [rows] = await pool.query('SELECT id, total FROM orders WHERE id = ?', [id])
// rows[0].id is a string — safe to JSON.stringify and ship to JSPrisma: bigint columns map to TypeScript BigInt by default in recent versions — JSON.stringify will throw unless you register a global replacer (BigInt.prototype.toJSON = function () { return this.toString() }) or convert in your response handler. The same warning applies: prefer per-route handling over global patching so other code keeps working.
Snowflake IDs (Twitter), Discord IDs, Stripe IDs — real-world cases
The big consumer APIs each chose a different strategy, and you can learn from their tradeoffs without repeating their mistakes. The pattern: anyone designing today picks strings. Anyone with a legacy numeric field carries it forever.
| API | ID format on the wire | Why |
|---|---|---|
| Twitter / X | Both id (number) and id_str (string) | Original Snowflake IDs were JSON numbers; id_str added later for JavaScript safety. Docs recommend always using id_str. |
| Discord | String only — every Snowflake is a quoted string | Built after the lessons of Twitter; no legacy numeric field exists. The cleanest design — no foot-gun to integrate against. |
| Stripe | Opaque strings (cus_xxx, pi_xxx) | IDs are namespaced strings, not numbers. Sidesteps the precision problem entirely; gains type tagging as a bonus. |
| GitHub | Integer (node IDs are strings, REST IDs are JSON numbers below 2^53) | Older REST API uses int32-range numbers; newer GraphQL API uses opaque base64 string node IDs. |
| YouTube | Opaque strings (dQw4w9WgXcQ) | 11-character base64-ish video IDs. Never numeric on the wire — same logic as Stripe. |
Snowflake IDs (Twitter / Discord format): 64-bit values where the high bits encode a timestamp, the middle bits encode a worker/shard, and the low bits are a sequence. The whole point of packing them into 64 bits is to fit a database bigint and stay sortable by time — which means they will regularly exceed 2^53 and must be treated as strings on the wire.
The takeaway: if you are picking the format for a new API today, ship strings. The cost is minor (one explicit conversion when arithmetic is needed); the benefit is permanent (no precision bug will ever surface from this field).
OpenAPI / JSON Schema: type: integer + format: int64 vs string
OpenAPI and JSON Schema both support a type: integer with format: int64 to declare a 64-bit signed integer. The format is honored on the server side (Java code generators emit Long; Go generators emit int64) but is meaningless to a JavaScript client — the generated TypeScript type is number, which means precision loss for any value above 2^53. The honest schema for a 64-bit identifier that JavaScript clients will read is type: string.
# OpenAPI 3.1
components:
schemas:
UserLegacy:
type: object
properties:
id:
type: integer
format: int64 # Java/Go honor this; JS client gets number (lossy)
example: 9007199254740993
User: # Safer schema
type: object
required: [id, name]
properties:
id:
type: string
pattern: '^[0-9]+$' # numeric-only string
example: '9007199254740993'
name: { type: string }The same principle applies to JSON Schema (Draft 2020-12) — declare large identifiers as { "type": "string", "pattern": "^[0-9]+$" }. For binary safety and stable hashing across implementations, see JSON canonicalization. For the full type field reference (integer vs number vs string handling across languages) see JSON Schema types. Other sibling guides cover related wire-format choices: UTF-8 encoding for string fields and Empty collections for the null vs [] vs missing-field decision.
The single most useful safety net is a round-trip integration test that pushes a value above 2^53 through the actual API surface — POST it, GET it back, assert byte-equality. Mocks skip the driver and serializer layers where the precision bug usually lives.
// Vitest integration test against the real API
import { describe, it, expect } from 'vitest'
const BIG_ID = '9007199254740993' // 2^53 + 1 — first unsafe value
describe('64-bit ID round-trip', () => {
it('preserves exact value above MAX_SAFE_INTEGER', async () => {
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ id: BIG_ID, name: 'alice' }),
})
const fetched = await fetch(`/api/users/${BIG_ID}`).then((r) => r.json())
expect(fetched.id).toBe(BIG_ID) // exact string, no rounding
// Negative control — confirms the test would catch a regression
const unsafe = JSON.parse(`{ "id": ${BIG_ID} }`)
expect(String(unsafe.id)).not.toBe(BIG_ID)
})
})Key terms
- BigInt
- A JavaScript primitive (added in ES2020) for integers of arbitrary precision. Created with the
nliteral suffix (9007199254740993n) or theBigInt()function. Distinct fromNumber— cannot be mixed in arithmetic without an explicit conversion. - MAX_SAFE_INTEGER
- The largest integer JavaScript can represent exactly as a
Number:2^53 - 1 = 9,007,199,254,740,991. Beyond this value, IEEE-754 doubles round and arithmetic loses its inverse. - Snowflake ID
- A 64-bit identifier scheme popularized by Twitter: timestamp + worker/shard + sequence packed into a single integer. Used by Twitter, Discord, and many internal company ID schemes. Always exceeds
2^53in practice. - json-bigint
- An npm package that replaces
JSON.parsewith a parser aware of integers aboveNumber.MAX_SAFE_INTEGER. ReturnsBigInt(orbignumber.js, configurable) for oversized integers; ships a matchingstringifyfor round-trips. - reviver
- The second argument to
JSON.parse— a callback invoked for every key/value pair while the parser walks the tree. Lets you transform values during parsing (string-to-BigInt, string-to-Date) without a separate post-processing step. - OID 20
- The PostgreSQL type identifier for
bigint. Used innode-postgresviapg.types.setTypeParser(20, fn)to control how the driver decodes the column — typically left as the default (return string). - branded type
- A TypeScript pattern (
string & { __brand: 'UserId' }) that adds compile-time tagging to a primitive without runtime overhead. Prevents accidental assignment of one ID type to another while keeping the wire representation as a plain string.
Frequently asked questions
Why does JSON.parse lose precision on large numbers?
JSON.parse loses precision because JavaScript stores every number as an IEEE 754 64-bit double. A double has 52 bits of mantissa plus an implicit leading 1, giving exact integer representation only up to 2^53 - 1 = 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). Beyond that, integers round to the nearest representable double. JSON.parse("9007199254740993") returns 9007199254740992 — silently off by one. The JSON spec itself does not constrain number precision; the loss happens entirely inside the JavaScript runtime when the parser builds a Number value. Other languages do not have this problem: Python uses arbitrary-precision int, Java has long (64-bit) plus BigInteger, Rust has i64/u64. The fix on the client is to keep the value out of the Number type — either send it as a string or parse it directly into a BigInt using a non-default parser.
What is MAX_SAFE_INTEGER in JavaScript?
Number.MAX_SAFE_INTEGER is the largest integer that JavaScript can represent exactly as a Number — its value is 2^53 - 1, which is 9,007,199,254,740,991. "Safe" means two things hold for every integer at or below this value: (a) it can be stored exactly as a 64-bit float without rounding, and (b) no other integer rounds to the same float. Above MAX_SAFE_INTEGER, both guarantees break: 9007199254740993 and 9007199254740992 share the same double representation, and arithmetic such as n + 1 may return n unchanged. The mirror constant Number.MIN_SAFE_INTEGER is -(2^53 - 1). Use Number.isSafeInteger(n) to check at runtime. Any 64-bit identifier (Twitter Snowflake, Discord ID, Postgres bigint, Java Long) can exceed this boundary, so plan the wire format before the first user signs up.
How do I send a 64-bit integer ID safely in JSON?
The simplest, most portable answer: send it as a JSON string, not a JSON number. "id": "1234567890123456789" parses identically in every language without any precision concerns, and TypeScript can type it as a branded string for safety. The cost is that the client cannot do arithmetic on the value without an explicit BigInt(id) conversion — which is usually fine because IDs are opaque tokens, not quantities. If the value really is a number you need to do math on (a balance, a tick count) and you control both sides, ship a non-default parser on the JavaScript client: either json-bigint as a drop-in replacement for JSON.parse, or a custom reviver that maps specific fields to BigInt. Mixing strategies is also common — Twitter ships both an id (number, lossy in JS) and an id_str (string, safe everywhere).
What is json-bigint and how do I use it?
json-bigint is an npm package (a fork of json-parse-better-errors) that replaces JSON.parse with a parser aware of integers larger than Number.MAX_SAFE_INTEGER. By default, oversized integers come back as BigInt; you can also configure it to return a bignumber.js instance or a plain string. Usage: import JSONbig from "json-bigint"; const parsed = JSONbig({ useNativeBigInt: true }).parse(text). It also provides a matching stringify so BigInt values round-trip without manual conversion. Use it when you genuinely need numeric semantics on the client (arithmetic, comparisons) and switching the API to strings is not feasible. It costs a parser swap on every fetch path that touches large numbers, so prefer it as a per-endpoint choice — not as a global JSON.parse replacement — to avoid surprising other code that assumes Number values.
How do Twitter and Discord handle their Snowflake IDs?
Twitter and Discord both use 64-bit Snowflake IDs (timestamp + worker + sequence packed into 64 bits), but they ship them differently. Twitter sends both shapes for compatibility: the original id field is a JSON number (which JavaScript clients round) and id_str is the canonical string copy. Their API docs recommend always using id_str on the wire and treating id as deprecated for new code. Discord went further — every ID in the Discord API is a string from the start. There is no numeric version. The Discord approach is cleaner because there is no foot-gun field for new integrators to accidentally use. Stripe and Snowflake (the data warehouse, unrelated to Snowflake IDs) follow the same string-only pattern for resource identifiers. The lesson: if you are designing a new API in 2026, ship strings, not numbers, for any identifier that could grow past 2^53.
Should I use BigInt or string for IDs in TypeScript?
For most application code, string with a branded type beats BigInt. Branded strings (type UserId = string & { __brand: "UserId" }) give you compile-time safety against mixing identifiers without any runtime overhead, serialize cleanly to JSON, work with localStorage and URLs, and never trigger the IEEE-754 boundary problem. BigInt is the right choice only when you actually do arithmetic on the value — counting tokens, summing balances, computing offsets — and even then it propagates: JSON.stringify cannot serialize BigInt without a custom replacer, every library you hand the value to may or may not understand it, and 1n === 1 is false. So the rule of thumb is: IDs and opaque tokens become branded strings; quantitative values that exceed Number.MAX_SAFE_INTEGER become BigInt with an explicit replacer/reviver pair pinned to the field set.
How does Python json.loads handle large integers?
Python json.loads parses large integers losslessly because Python int is arbitrary precision — there is no fixed-width upper bound. json.loads("123456789012345678901234567890") returns the exact integer, no rounding, no overflow, no special configuration. This is a major reason Python backends are well-suited to relaying 64-bit IDs from a database (Postgres bigint, MySQL BIGINT) to a JSON response: the value never gets squeezed through a 53-bit window on the server. The catch is that whatever you send still has to survive the JavaScript client. If your API serves Python services or curl-based scripts only, you can ship integers directly. If a browser will ever parse the response, ship strings or accept that the client must use a BigInt-aware parser. json.loads also accepts a parse_int hook to map every integer to a custom type (str, Decimal) if you want a consistent server-side strategy.
Why does my Postgres BIGINT come back as a string in pg driver?
node-postgres (the pg npm package) returns BIGINT (OID 20) as a string by default, on purpose. The driver authors made that call because a Postgres BIGINT is a signed 64-bit integer with a maximum of 2^63 - 1 = 9,223,372,036,854,775,807 — well past JavaScript Number.MAX_SAFE_INTEGER. Returning the value as a Number would silently corrupt any ID above 2^53. To get a Number instead (only safe when you know the column never exceeds 2^53), call pg.types.setTypeParser(20, val => parseInt(val, 10)). To get a BigInt, use pg.types.setTypeParser(20, val => BigInt(val)). The string default is the conservative choice — explicit, lossless, and consistent with how the JSON API guides above recommend shipping 64-bit values on the wire. Other drivers (mysql2, prisma) have similar settings under names like supportBigNumbers or bigIntType.
Further reading and primary sources
- MDN — Number.MAX_SAFE_INTEGER — Authoritative reference for the 2^53 boundary and Number.isSafeInteger
- MDN — BigInt — BigInt primitive: literal syntax, arithmetic rules, and serialization caveats
- json-bigint on npm — BigInt-aware drop-in replacement for JSON.parse with matching stringify
- node-postgres — Data types — pg.types.setTypeParser for BIGINT (OID 20) and other column types
- Twitter API — Twitter IDs — Why id_str exists and when JavaScript clients should use it over id
- Discord API — Snowflakes — Discord ID format and the rationale for string-only on the wire