JSON Number Precision: IEEE 754, BigInt, and Decimal.js
Last updated:
JSON numbers have no precision limit in the spec, but JavaScript's IEEE 754 double-precision floating-point caps safe integers at ±9,007,199,254,740,991 (2^53 − 1) — JSON.parse("9007199254740993") silently returns 9007199254740992 with no error thrown. Python and Java parse the same large integer exactly, making this a cross-language interoperability bug for IDs, Snowflake IDs, and 64-bit database primary keys. Financial arithmetic adds a 2nd problem: 0.1 + 0.2 === 0.30000000000000004 in IEEE 754 — even small decimals accumulate rounding error, which is why currency should always be stored as integer cents. This guide covers Number.MAX_SAFE_INTEGER, Number.isSafeInteger(), BigInt for IDs over 53 bits, Decimal.js for financial precision, custom JSON.parse revivers for large integers, and JSON Schema multipleOf for currency validation.
Inspect Number Precision in Your JSON
Paste JSON with large integers or decimals to instantly detect precision loss before it reaches production.
Open JSON FormatterIEEE 754 Double-Precision: Why JSON Numbers Have a 53-Bit Integer Limit
The JSON specification (RFC 8259) places no precision limit on numbers — a JSON number is simply a sequence of digits, optionally with a decimal point and exponent. The 53-bit integer limit is a consequence of how JavaScript (and every IEEE 754-based runtime) stores numbers internally. Every JavaScript number — whether you write 1, 9007199254740991, or 3.14 — is stored as a 64-bit double-precision float.
A 64-bit IEEE 754 double uses 1 bit for sign, 11 bits for exponent, and 52 bits for the fractional mantissa. The implicit leading 1 bit gives 53 bits of significand total. This means integers up to 2^53-1 = 9,007,199,254,740,991 can be represented exactly. Beyond this threshold, consecutive integers share the same double representation — two distinct mathematical integers map to the same float value. The JavaScript engine cannot distinguish them after parsing.
// IEEE 754 double-precision: 53 bits of significand
console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991 (2^53 - 1)
console.log(Number.MIN_SAFE_INTEGER) // -9007199254740991
// Integers beyond the safe range collapse to the same double value
console.log(9007199254740992 === 9007199254740993) // true — DANGEROUS
console.log(9007199254740993 === 9007199254740994) // true
// JSON.parse() silently rounds
const parsed = JSON.parse('{"id": 9007199254740993}')
console.log(parsed.id) // 9007199254740992 — WRONG
console.log(parsed.id === 9007199254740993) // false
// 64-bit double bit layout:
// [1 sign][11 exponent][52 mantissa] = 64 bits
// Integer precision = 52 mantissa bits + 1 implicit = 53 bits
// Max exact integer = 2^53 - 1 = 9,007,199,254,740,991
// Number.isSafeInteger detects the boundary
console.log(Number.isSafeInteger(9007199254740991)) // true (2^53 - 1)
console.log(Number.isSafeInteger(9007199254740992)) // false (2^53)
console.log(Number.isSafeInteger(9007199254740993)) // false (2^53 + 1)
// Floating-point fractions also have binary representation errors
console.log(0.1 + 0.2) // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3) // false
// Python comparison — no precision loss for integers
// import json
// data = json.loads('{"id": 9007199254740993}')
// print(data['id']) # 9007199254740993 — CORRECT (Python arbitrary-precision int)
// print(type(data['id'])) # <class 'int'>The rounding is silent. JavaScript throws no exception, logs no warning, and returns no indication that data was lost. This makes the bug difficult to detect in testing — test data with IDs below 2^53 passes fine, and the problem only surfaces in production with real 64-bit IDs. The practical implication: any database using 64-bit integer primary keys (PostgreSQL bigint, MySQL BIGINT, MongoDB ObjectId-derived integers) produces IDs that may exceed Number.MAX_SAFE_INTEGER and cause silent corruption in JavaScript clients.
Number.MAX_SAFE_INTEGER and Number.isSafeInteger()
Number.MAX_SAFE_INTEGER (added in ES2015) is the authoritative constant for the safe integer boundary. Number.isSafeInteger(n) returns true if and only if n is a finite integer with Math.abs(n) <= Number.MAX_SAFE_INTEGER. Use it to validate integers before serializing to JSON or after parsing — catching unsafe values at the boundary prevents silent corruption from propagating deeper into your system.
// Number.MAX_SAFE_INTEGER and Number.isSafeInteger
console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991
console.log(Number.MIN_SAFE_INTEGER) // -9007199254740991
console.log(Number.isSafeInteger(42)) // true
console.log(Number.isSafeInteger(9007199254740991)) // true
console.log(Number.isSafeInteger(9007199254740992)) // false — 2^53 edge case
console.log(Number.isSafeInteger(3.14)) // false — not an integer
console.log(Number.isSafeInteger(Infinity)) // false
// Guard function: throw before sending unsafe integers to clients
function toSafeJsonNumber(n: number): number {
if (!Number.isSafeInteger(n)) {
throw new RangeError(
`Integer ${n} exceeds Number.MAX_SAFE_INTEGER — use string encoding instead`
)
}
return n
}
// Walk parsed JSON and warn about unsafe integers
function auditJsonNumbers(value: unknown, path = ''): void {
if (typeof value === 'number' && !Number.isSafeInteger(value) && Number.isInteger(value)) {
console.warn(`Unsafe integer at ${path}: ${value}`)
} else if (value !== null && typeof value === 'object') {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
auditJsonNumbers(v, path ? `${path}.${k}` : k)
}
}
}
const payload = JSON.parse('{"id": 9007199254740993, "count": 42}')
auditJsonNumbers(payload)
// Unsafe integer at id: 9007199254740992 ← already rounded by JSON.parse!
// Correct pattern: intercept with a custom reviver BEFORE values are coerced
// (see Section 4 for custom reviver implementation)
// TypeScript utility: assert safe integer
function assertSafeInteger(n: number, label: string): asserts n is number {
if (!Number.isSafeInteger(n)) {
throw new RangeError(`${label} = ${n} is not a safe integer`)
}
}
// ES2020 BigInt alternative for comparison
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER) // 9007199254740991n
const id = 9007199254740993n
console.log(id > MAX_SAFE) // true — use BigInt for large IDsNote that Number.isSafeInteger(9007199254740992) returns false even though 2^53 is exactly representable as a double. The standard defines "safe" as the range where every integer has a unique double representation and no neighboring integers are rounded to it — 2^53 fails this because 2^53+1 rounds to it. Always test both boundaries: the maximum and the adjacent value.
BigInt for Large JSON IDs (Twitter IDs, Database IDs)
JavaScript's BigInt type (introduced in ES2020) provides arbitrary-precision integers with no upper bound. It is the correct native solution for large JSON integer IDs — Twitter/X Snowflake IDs (64-bit), Discord user IDs, PostgreSQL bigint primary keys, and UUID-derived integer IDs all require BigInt handling in JavaScript. The challenge is that the standard JSON.parse() converts large integers to doubles before you can intercept them.
// ── BigInt basics ─────────────────────────────────────────────────
const id = 9007199254740993n // BigInt literal
console.log(typeof id) // "bigint"
console.log(id === 9007199254740993n) // true — exact comparison
console.log(id + 1n) // 9007199254740994n
// BigInt cannot mix with number — explicit conversion required
// console.log(id + 1) // TypeError: Cannot mix BigInt and other types
// ── Problem: JSON.parse rounds before you can use BigInt ──────────
const bad = JSON.parse('{"id": 9007199254740993}')
console.log(bad.id) // 9007199254740992 — already wrong
// ── Solution 1: json-bigint library ──────────────────────────────
// npm install json-bigint
import JSONbig from 'json-bigint'
const JSONbigNative = JSONbig({ useNativeBigInt: true })
const parsed = JSONbigNative.parse('{"id": 9007199254740993, "count": 42}')
console.log(parsed.id) // 9007199254740993n (BigInt)
console.log(typeof parsed.id) // "bigint"
console.log(parsed.count) // 42 (regular number — safe range)
// Serialize back — BigInt becomes a bare JSON number
const serialized = JSONbigNative.stringify(parsed)
console.log(serialized) // '{"id":9007199254740993,"count":42}'
// ── Solution 2: lossless-json library ────────────────────────────
// npm install lossless-json
import { parse, stringify } from 'lossless-json'
const data = parse('{"id": 9007199254740993, "price": 9.99}')
// data.id is a LosslessNumber — not yet converted to number or BigInt
const safeId = BigInt(data.id) // 9007199254740993n — exact
const price = Number(data.price) // 9.99
// Attempting Number(data.id) throws if value exceeds safe integer range
// ── Solution 3: string encoding (most portable) ───────────────────
// Server serializes ID as a string
const serverResponse = JSON.stringify({ id: String(9007199254740993n), name: 'Alice' })
console.log(serverResponse) // '{"id":"9007199254740993","name":"Alice"}'
// Client parses safely — string requires no special handling
const client = JSON.parse(serverResponse)
const clientId = BigInt(client.id) // 9007199254740993n — exact
console.log(clientId === 9007199254740993n) // true
// ── Snowflake ID example (Discord/Twitter pattern) ────────────────
// Discord Snowflake: 64-bit integer, typically 17-19 digits
const discordId = '123456789012345678' // received as string from API
const flake = BigInt(discordId) // 123456789012345678n
const timestamp = flake >> 22n // extract timestamp bits
console.log(new Date(Number(timestamp) + 1420070400000)) // Discord epochString encoding is the most portable approach because it requires no special library on the client side — every JSON parser handles strings without precision loss. The downside is that consumers must know which fields are large integers and convert them explicitly. A common convention is to suffix the field name: id_str alongside id (Twitter's v1 API pattern), or to document string-encoded integer fields in your API schema using JSON Schema with a contentEncoding annotation.
Custom JSON Replacer and Reviver for BigInt
JSON.stringify() and JSON.parse() both accept a second argument — a replacer and a reviver respectively — that let you intercept values during serialization and parsing. These are the built-in hooks for BigInt support without a third-party library. The critical constraint: the reviver receives values after they have been parsed as JavaScript primitives, so integers beyond 2^53 are already rounded by the time the reviver runs. The replacer, however, receives BigInt values before serialization, allowing correct string conversion.
// ── Replacer: serialize BigInt as JSON string ─────────────────────
function bigintReplacer(_key: string, value: unknown): unknown {
if (typeof value === 'bigint') {
return value.toString() // becomes a quoted JSON string
}
return value
}
const payload = { id: 9007199254740993n, name: 'Alice', score: 42 }
const json = JSON.stringify(payload, bigintReplacer)
console.log(json)
// '{"id":"9007199254740993","name":"Alice","score":42}'
// Note: id is a quoted string, not a bare number
// ── Replacer: serialize BigInt as bare JSON number (non-standard) ─
// Some APIs expect bare numbers — workaround using JSON.stringify trick
function bigintToNumberReplacer(_key: string, value: unknown): unknown {
if (typeof value === 'bigint') {
// Return a placeholder and replace after stringify
// Safer: use a number-safe JSON library instead
return value.toString()
}
return value
}
// Then strip quotes around the number field with a regex — fragile, avoid in production
// ── Reviver: WARNING — integers are already rounded by JSON.parse ─
// A reviver CANNOT recover precision lost during initial number parsing.
// JSON.parse rounds 9007199254740993 to 9007199254740992 before the reviver runs.
const alreadyRounded = JSON.parse('{"id": 9007199254740993}', (key, value) => {
if (key === 'id') return BigInt(value) // BigInt(9007199254740992) — still wrong!
return value
})
console.log(alreadyRounded.id) // 9007199254740992n — incorrect
// ── Correct reviver: use string-encoded integers on the wire ───────
// Server sends: {"id": "9007199254740993"} (string, not number)
const safeJson = '{"id": "9007199254740993", "name": "Alice", "score": 42}'
const revived = JSON.parse(safeJson, (key, value) => {
// Convert string fields that look like large integers to BigInt
if (typeof value === 'string' && /^-?d{16,}$/.test(value)) {
return BigInt(value)
}
return value
})
console.log(revived.id) // 9007199254740993n — correct
console.log(revived.score) // 42 (regular number, untouched)
// ── TypeScript typed BigInt reviver ──────────────────────────────
type BigIntFields = 'id' | 'userId' | 'snowflakeId'
const BIGINT_FIELDS = new Set<string>(['id', 'userId', 'snowflakeId'])
function bigintReviver(key: string, value: unknown): unknown {
if (BIGINT_FIELDS.has(key) && typeof value === 'string') {
return BigInt(value)
}
return value
}
// ── Python comparison ─────────────────────────────────────────────
// Python's json.dumps does not serialize BigInt directly (no native BigInt type)
// but Python int handles large values:
// import json
// data = {"id": 9007199254740993, "name": "Alice"}
// json.dumps(data) # '{"id": 9007199254740993, "name": "Alice"}'
// # JavaScript clients will round id — output as string instead:
// data_str = {"id": str(9007199254740993), "name": "Alice"}
// json.dumps(data_str) # '{"id": "9007199254740993", "name": "Alice"}'The key insight: use string-encoded integers on the wire (the JSON payload) and a reviver to reconstruct BigInt on the client. A reviver that tries to fix already-rounded numbers is useless — precision lost during JSON.parse()'s number parsing cannot be recovered. The only way to preserve precision through JSON.parse() is to never send large integers as bare JSON numbers in the first place. See the TypeScript JSON types guide for typing parsed BigInt fields correctly.
Decimal.js and Big.js for Financial Calculations
Financial calculations require exact decimal arithmetic, not binary floating-point approximation. The root cause: most decimal fractions (0.1, 0.2, 0.3...) have no exact binary representation — just as 1/3 has no exact decimal representation. IEEE 754 stores the closest approximation, and errors accumulate across operations. Decimal.js and Big.js solve this by performing all arithmetic in base-10 with arbitrary precision, completely avoiding binary floating-point internally.
// npm install decimal.js
import Decimal from 'decimal.js'
// ── The IEEE 754 float problem ────────────────────────────────────
console.log(0.1 + 0.2) // 0.30000000000000004 — WRONG
console.log(0.1 + 0.2 === 0.3) // false
// ── Decimal.js: exact decimal arithmetic ──────────────────────────
const a = new Decimal('0.1')
const b = new Decimal('0.2')
console.log(a.plus(b).toString()) // "0.3" — exact
console.log(a.plus(b).equals('0.3')) // true
// ── Financial calculation example ────────────────────────────────
// Price: $9.99, quantity: 3, tax: 8.5%
const price = new Decimal('9.99')
const qty = new Decimal('3')
const tax = new Decimal('0.085')
const subtotal = price.times(qty) // Decimal("29.97")
const taxAmount = subtotal.times(tax) // Decimal("2.54745")
const total = subtotal.plus(taxAmount) // Decimal("32.51745")
const rounded = total.toDecimalPlaces(2, Decimal.ROUND_HALF_UP)
console.log(rounded.toString()) // "32.52"
// ── JSON serialization pattern for financial data ─────────────────
// Store as string in JSON to preserve precision
const lineItem = {
price: price.toString(), // "9.99"
quantity: qty.toNumber(), // 3 (safe integer)
subtotal: subtotal.toString(), // "29.97"
tax_rate: tax.toString(), // "0.085"
total: rounded.toString(), // "32.52"
}
const json = JSON.stringify(lineItem)
// '{"price":"9.99","quantity":3,"subtotal":"29.97","tax_rate":"0.085","total":"32.52"}'
// Deserialize and reconstruct Decimal values
const parsed = JSON.parse(json)
const parsedTotal = new Decimal(parsed.total) // Decimal("32.52") — exact
// ── Big.js: lighter alternative for simple cases ─────────────────
// npm install big.js
import Big from 'big.js'
const x = new Big('0.1').plus('0.2')
console.log(x.toString()) // "0.3"
// Big.js vs Decimal.js comparison:
// Big.js: ~6KB gzip, fewer features, no NaN/Infinity handling
// Decimal.js: ~15KB gzip, trigonometry, configurable rounding modes, NaN/Infinity
// ── Decimal.js configuration ──────────────────────────────────────
Decimal.set({ precision: 28, rounding: Decimal.ROUND_HALF_UP })
// precision: number of significant digits (default 20, max 1e+9000)
// rounding modes: ROUND_UP, ROUND_DOWN, ROUND_CEIL, ROUND_FLOOR,
// ROUND_HALF_UP, ROUND_HALF_EVEN (banker's rounding)
// ── Currency-specific patterns ────────────────────────────────────
// USD: 2 decimal places, ROUND_HALF_UP
const usd = new Decimal('1.005').toDecimalPlaces(2, Decimal.ROUND_HALF_UP)
console.log(usd.toString()) // "1.01"
// BTC: 8 decimal places (satoshis)
const btc = new Decimal('0.00000001') // 1 satoshi
console.log(btc.toFixed(8)) // "0.00000001"
// Integer cents approach (avoids Decimal.js for simple cases)
const priceCents = 999 // $9.99 — safe integer, no decimal library needed
const totalCents = priceCents * 3 // 2997 — exact
console.log((totalCents / 100).toFixed(2)) // "29.97" — display onlyFor simple integer-cents storage, Decimal.js is not needed — keep prices as integers throughout and only convert to display strings at the UI layer. Decimal.js becomes necessary when you deal with tax rates, percentage discounts, or currencies with more than 2 decimal places (JPY has 0, BTC has 8, ETH has 18). Always store financial values in JSON as strings ("price": "9.99") rather than floating-point numbers — this ensures round-trip precision even when deserializing in environments that do not use Decimal.js.
Cross-Language Number Precision: Python, Java, and Rust
The JSON number precision problem is not universal — it is specific to environments that map all JSON numbers to IEEE 754 doubles. Python, Java, and Rust all handle large JSON integers correctly by default or with a single configuration option, making them safe server-side choices for generating JSON with large integer IDs.
# ── Python: arbitrary-precision integers ─────────────────────────
import json
from decimal import Decimal
# Large integers — no precision loss
data = json.loads('{"id": 9007199254740993, "small": 42}')
print(data['id']) # 9007199254740993 — CORRECT
print(type(data['id'])) # <class 'int'> — arbitrary-precision
# Python float precision — same issue as JavaScript
print(0.1 + 0.2) # 0.30000000000000004
print(json.loads('0.1') + json.loads('0.2')) # 0.30000000000000004
# Python Decimal for financial calculations
from decimal import Decimal, ROUND_HALF_UP
price = Decimal('9.99')
qty = Decimal('3')
total = (price * qty).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
print(total) # 29.97
# Serialize large int as string for JS clients
user_id = 9007199254740993
response = json.dumps({"id": str(user_id), "name": "Alice"})
print(response) # '{"id": "9007199254740993", "name": "Alice"}'// ── Java: Jackson handles 64-bit integers exactly ────────────────
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import java.math.BigDecimal;
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree("{"id": 9007199254740993, "price": 9.99}");
// Jackson auto-selects type: int -> Integer, long -> Long, larger -> BigInteger
long id = node.get("id").longValue(); // 9007199254740993L — exact (fits in long)
System.out.println(id == 9007199254740993L); // true
// For values beyond Long.MAX_VALUE (2^63-1), Jackson uses BigInteger
JsonNode huge = mapper.readTree("{"n": 99999999999999999999}");
java.math.BigInteger bigN = huge.get("n").bigIntegerValue();
// BigDecimal for financial arithmetic
BigDecimal price = node.get("price").decimalValue(); // 9.99 exactly
BigDecimal qty = new BigDecimal("3");
BigDecimal total = price.multiply(qty)
.setScale(2, java.math.RoundingMode.HALF_UP);
System.out.println(total); // 29.97
// Serialize long as string for JavaScript clients
// Use @JsonSerialize(using = ToStringSerializer.class) on the field
// or: mapper.writeValueAsString(Map.of("id", Long.toString(id)))// ── Rust: serde_json with explicit types ─────────────────────────
use serde::{Deserialize, Serialize};
use serde_json::Value;
// Deserialize into u64 — exact, no precision loss
#[derive(Deserialize, Serialize)]
struct Record {
id: u64, // 64-bit unsigned: 0 to 18,446,744,073,709,551,615
price: f64, // IEEE 754 double — same float precision as JS
price_str: String, // string-encoded for exact decimal
}
let json = r#"{"id": 9007199254740993, "price": 9.99, "price_str": "9.99"}"#;
let record: Record = serde_json::from_str(json).unwrap();
println!("{}", record.id); // 9007199254740993 — exact
println!("{}", record.price); // 9.99 (approximate — same as f64)
// Using Value for dynamic JSON — numbers stored as f64 by default
let v: Value = serde_json::from_str(r#"{"id": 9007199254740993}"#).unwrap();
println!("{}", v["id"]); // 9007199254740992 — same precision loss!
// Use serde_json::Number::as_u64() which reads the original representation
if let Some(n) = v["id"].as_u64() { println!("{}", n); } // 9007199254740993
// ── Go: use json.Number for large integers ────────────────────────
// var data map[string]interface{}
// d := json.NewDecoder(strings.NewReader(text))
// d.UseNumber() // critical: disables float64 default
// d.Decode(&data)
// n, _ := data["id"].(json.Number).Int64() // 9007199254740993 — exactThe practical rule: if your backend is Python or Java, large integer IDs parse correctly on the server. But if any JavaScript client consumes your API response, you must still encode those IDs as strings — the server-side precision is irrelevant once the response reaches a browser or Node.js JSON.parse() call. Go and Rust require opt-in configuration (UseNumber() and u64 respectively) to avoid the same precision loss as JavaScript.
JSON Schema Number Constraints: minimum, maximum, multipleOf
JSON Schema provides minimum, maximum, exclusiveMinimum, exclusiveMaximum, and multipleOf keywords for numeric validation. The type keyword distinguishes "integer" (no fractional part) from "number" (any numeric value). These constraints let you document and enforce precision boundaries at the schema level, which is especially important for TypeScript JSON types and API contracts.
// ── JSON Schema: integer vs number ────────────────────────────────
{
"type": "integer" // valid: 42, -7, 0 — invalid: 3.14, "42"
}
{
"type": "number" // valid: 42, 3.14, -7.5 — invalid: "3.14"
}
// ── Safe integer range constraint ──────────────────────────────────
{
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
// ── Large ID as string (recommended pattern) ───────────────────────
{
"type": "string",
"pattern": "^[0-9]{1,20}$",
"description": "64-bit integer ID encoded as string to preserve precision in JavaScript"
}
// ── Currency in cents (integer, non-negative) ──────────────────────
{
"type": "integer",
"minimum": 0,
"description": "Price in USD cents. $9.99 = 999"
}
// ── multipleOf for decimal currency (use with care) ───────────────
{
"type": "number",
"minimum": 0,
"multipleOf": 0.01,
"description": "Price in USD with up to 2 decimal places"
}
// WARNING: floating-point multipleOf validation can produce false negatives.
// 0.1 + 0.2 = 0.30000000000000004 — may fail multipleOf: 0.1 check.
// Prefer integer cents over decimal multipleOf for currency.
// ── multipleOf for integer cents (safe) ───────────────────────────
{
"type": "integer",
"minimum": 0,
"multipleOf": 1,
"description": "Always valid for integers — equivalent to 'type: integer'"
}
// ── Full line item schema ──────────────────────────────────────────
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "price_cents", "quantity"],
"properties": {
"id": {
"type": "string",
"pattern": "^[0-9]{1,20}$",
"description": "Order ID — 64-bit integer encoded as string"
},
"price_cents": {
"type": "integer",
"minimum": 0,
"maximum": 999999999
},
"quantity": {
"type": "integer",
"minimum": 1,
"maximum": 10000
},
"discount_bps": {
"type": "integer",
"minimum": 0,
"maximum": 10000,
"description": "Discount in basis points (100 bps = 1%)"
}
},
"additionalProperties": false
}Basis points (1 bps = 0.01%) are a common pattern for expressing discount and interest rates as safe integers — 1000 bps represents 10%, eliminating decimal arithmetic entirely. The multipleOf keyword with floating-point divisors (e.g., 0.01) is defined in the JSON Schema specification but produces unreliable results in practice because the validator must perform IEEE 754 arithmetic to check divisibility. For production financial schemas, always prefer integer representation. See the JSON Schema guide for complete validation keyword reference.
Key Terms
- IEEE 754
- The IEEE Standard for Floating-Point Arithmetic, published in 1985 and revised in 2008. Defines the binary representation of floating-point numbers used by virtually all modern CPUs, programming languages, and the JavaScript runtime. The standard specifies multiple precisions: single (32-bit), double (64-bit), and extended. JavaScript uses double-precision for all numbers. The standard also defines special values: NaN (Not a Number), positive and negative Infinity, and signed zero (+0 and -0). IEEE 754 is the root cause of
0.1 + 0.2 !== 0.3— the standard trades decimal accuracy for binary speed and hardware simplicity. - double-precision float
- A 64-bit IEEE 754 floating-point number consisting of 1 sign bit, 11 exponent bits, and 52 explicit mantissa (significand) bits, for 53 total significant bits. This format can represent integers exactly up to 2^53-1 = 9,007,199,254,740,991 and approximate most decimal fractions within a relative error of about 2.2 × 10^-16. JavaScript's
numbertype is a double-precision float. The format can represent a very wide range of values (roughly ±1.8 × 10^308), but it provides only about 15-17 significant decimal digits of precision, not arbitrary precision. - Number.MAX_SAFE_INTEGER
- A JavaScript constant equal to 9,007,199,254,740,991 (2^53 - 1). It represents the largest integer that can be represented exactly as an IEEE 754 double-precision float and compared correctly — no two distinct integers at or below this value are rounded to the same double. The symmetric constant
Number.MIN_SAFE_INTEGERis -9,007,199,254,740,991. Introduced in ES2015 (ES6). UseNumber.isSafeInteger(n)to check at runtime. Any 64-bit database ID, Snowflake ID, or timestamp-derived ID that may exceed this value must be transmitted as a string or handled with BigInt. - BigInt
- A JavaScript primitive type (introduced in ES2020) representing integers of arbitrary size with no upper bound. BigInt literals use the
nsuffix:9007199254740993n. BigInt arithmetic is exact — no rounding, no overflow within available memory. BigInt cannot be mixed withnumberin arithmetic expressions without explicit conversion.JSON.stringify()throwsTypeErrorfor BigInt values — a custom replacer converting them to strings is required.JSON.parse()cannot produce BigInt values directly; use thejson-bigintlibrary or string-encoded integers with a reviver. - safe integer
- An integer
nsatisfyingMath.abs(n) <= Number.MAX_SAFE_INTEGER(i.e.,|n| <= 2^53 - 1). Within this range, every integer has a unique IEEE 754 double-precision representation — no two distinct safe integers compare as equal.Number.isSafeInteger(n)returnstruefor safe integers andfalsefor floats, non-finite values, or integers outside the safe range. The term "safe" refers to safety of comparison and identity, not arithmetic safety — adding two safe integers may produce an unsafe result:Number.MAX_SAFE_INTEGER + 1gives an unsafe integer that equalsNumber.MAX_SAFE_INTEGER + 2. - Decimal.js
- A JavaScript library (
npm install decimal.js) providing arbitrary-precision decimal arithmetic. Unlike IEEE 754 doubles, Decimal.js performs all arithmetic in base-10, eliminating binary fraction representation errors. Key features: configurable precision (default 20, up to 1e+9000 significant digits), configurable rounding modes (ROUND_HALF_UP, ROUND_HALF_EVEN for banker's rounding, etc.), and support for comparison, remainder, power, and logarithm operations. Big.js is a lighter alternative (~6KB vs ~15KB) for cases that only require basic arithmetic. Always initialize Decimal values from strings (new Decimal('9.99')), never from JavaScript floats (new Decimal(9.99)) — the float is already imprecise before Decimal.js receives it. - numeric string
- A JSON string value containing only numeric characters, used to transmit large integers or exact decimal values without precision loss. A numeric string like
"9007199254740993"is transmitted as a JSON string (quoted) rather than a JSON number (unquoted), so every parser handles it without any numeric conversion. The consumer must explicitly convert to the desired numeric type:BigInt("9007199254740993")in JavaScript,int("9007199254740993")in Python. Numeric strings are the most cross-platform approach for large IDs and are used by Twitter, Discord, and many financial APIs. A JSON Schemapatternconstraint ({"pattern": "^-?[0-9]+"}) can validate the format.
FAQ
Why does JSON.parse() lose precision for large numbers?
JSON.parse() loses precision for large integers because JavaScript converts every JSON number to its native number type — an IEEE 754 64-bit double-precision float. A 64-bit double has only 53 bits of mantissa (significand), which means it can represent integers exactly only up to 2^53-1 = 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). Any integer beyond this boundary may round to the nearest representable double value. For example, JSON.parse("9007199254740993") returns 9007199254740992 — silently wrong by 1. No error is thrown; the incorrect value is returned as if it were correct. The JSON specification itself imposes no precision limit on numbers — the loss is purely a JavaScript runtime constraint. Other languages such as Python (arbitrary-precision int), Java (Long/BigInteger), and Rust (i64/u64) parse large JSON integers exactly without rounding. See our guide on JSON.parse() for more on how the parser works.
What is Number.MAX_SAFE_INTEGER and why does it matter for JSON?
Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991 — exactly 2^53 minus 1. It is the largest integer that JavaScript can represent exactly as an IEEE 754 double-precision float. The symmetric lower bound is Number.MIN_SAFE_INTEGER = -9,007,199,254,740,991. Integers within this range are guaranteed to be represented exactly and can be compared correctly (a !== b implies their double representations differ). Integers outside this range may silently round. Number.isSafeInteger(n) returns true only if Math.abs(n) <= Number.MAX_SAFE_INTEGER. For JSON, this limit matters because JSON.parse() maps all JSON numbers to JavaScript number values. A 64-bit database ID, a Twitter/Discord Snowflake ID, or a Unix timestamp in microseconds can easily exceed this limit — causing silent data corruption in JavaScript clients while Python or Java clients handle the same JSON correctly. Always use Number.isSafeInteger() to validate integer values before transmitting them as JSON numbers.
How do I handle large integer IDs (like Twitter IDs) in JSON?
Three approaches work for large integer IDs in JSON: (1) String encoding — serialize the ID as a JSON string ("id": "9007199254740993") instead of a number. Every parser in every language handles strings without precision loss. On the client side, convert with BigInt("9007199254740993") when arithmetic is needed. Twitter's v1 API returned both "id" (number, broken in JS) and "id_str" (string, safe) for exactly this reason. Discord returns Snowflake IDs as strings by default. (2) json-bigint library (npm install json-bigint) — a drop-in replacement for JSON.parse() and JSON.stringify() that parses large JSON integers as JavaScript BigInt values. Use JSONbig({'{ useNativeBigInt: true }'}).parse(text). (3) lossless-json library — stores all numbers internally as strings and throws an error rather than silently rounding when you convert an unsafe integer to a JavaScript number. String encoding is the recommended approach when you control the API because it works with every consumer without requiring a special parser.
How do I serialize and parse BigInt in JSON?
JSON.stringify(9007199254740993n) throws TypeError: Do not know how to serialize a BigInt because the JSON standard has no BigInt type. The solution is a custom replacer function: JSON.stringify(value, (key, val) => typeof val === "bigint" ? val.toString() : val). This converts BigInt values to JSON strings. On the parse side, a custom reviver can detect string values that look like large integers and convert them back: JSON.parse(text, (key, val) => typeof val === "string" && /^-?[0-9]{'{'}{16},{'}'}$/.test(val) ? BigInt(val) : val). Alternatively, use the json-bigint library which handles both serialization and parsing transparently. Important caveat: a reviver applied to JSON.parse() runs after number parsing — large integers are already rounded before the reviver sees them. The reviver only works correctly when the wire format uses string-encoded integers, not bare JSON numbers.
How do I avoid floating-point precision errors in JSON financial data?
Never store monetary values as JSON floating-point numbers. IEEE 754 cannot represent most decimal fractions exactly — 0.1 + 0.2 equals 0.30000000000000004, not 0.3. Rounding errors compound across additions, subtractions, and multiplications in financial calculations. Three correct approaches: (1) Integer cents — store {"price_cents": 999} for $9.99. All arithmetic stays in safe integer range. Display by dividing: (999 / 100).toFixed(2). (2) Decimal strings — store {"price": "9.99"} and use Decimal.js for arithmetic: new Decimal("9.99").times("1.2").toFixed(2) returns "11.99" exactly. (3) Basis points for rates — store tax/discount rates as integer basis points: 850 bps = 8.5%, eliminating decimal arithmetic for rate calculations. For currencies with many decimal places (BTC = 8 decimal places, ETH = 18), string encoding with Decimal.js is essential — no integer representation is practical at those scales.
What is the difference between integer and number in JSON Schema?
JSON Schema defines two numeric types: "type": "number" accepts any JSON number value including integers and floating-point values; "type": "integer" accepts only values with no fractional part. Per the spec, 42 and 42.0 are both valid integer instances (42.0 has no fractional part), but 42.5 is not. Both types support the same constraint keywords: minimum, maximum, exclusiveMinimum, exclusiveMaximum, and multipleOf. To constrain the JavaScript safe integer range explicitly: {"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}. For financial values, use {"type": "integer", "minimum": 0} for prices in cents. JSON Schema validators check structural conformance but cannot detect IEEE 754 precision loss that occurs after parsing — schema validation runs on already-parsed values. See our full JSON Schema reference for all numeric keywords.
How do other languages handle large JSON integers?
Language behavior for large JSON integers varies significantly. Python: json.loads('{"id": 9007199254740993}') returns exactly 9007199254740993 as a Python int — Python integers are arbitrary precision with no upper bound. Java with Jackson: integers fitting in a long (up to 2^63-1 = 9,223,372,036,854,775,807) are parsed as long; larger values auto-promote to BigInteger. Java handles all 64-bit IDs exactly. Go: encoding/json decodes JSON numbers into float64 by default (same precision loss as JavaScript); use json.Decoder with d.UseNumber() to get a json.Number string type instead, then parse with strconv. Rust with serde_json: integers deserializing into u64 or i64 are exact; deserializing into f64 has the same 53-bit limit. PHP: json_decode() silently converts large integers to float; pass JSON_BIGINT_AS_STRING flag to get strings instead. The practical rule: if your API has JavaScript consumers, encode 64-bit IDs as strings regardless of server language.
How do I use multipleOf in JSON Schema for currency validation?
The multipleOf keyword in JSON Schema validates that a number is an exact multiple of a given value. For currency in cents, use {"type": "integer", "minimum": 0, "multipleOf": 1} — this enforces integer values (equivalent to "type": "integer"). For decimal currency stored as a number (not recommended for production), use {"type": "number", "multipleOf": 0.01} to allow values like 9.99 but reject 9.999. Important caveat: multipleOf validation with floating-point divisors can produce false negatives due to IEEE 754 arithmetic — 0.1 + 0.2 = 0.30000000000000004 might fail a multipleOf: 0.1 check even though it is conceptually a valid value. This is why storing currency as integer cents and using multipleOf: 1 avoids all floating-point validation problems. For basis points (interest and discount rates), {"type": "integer", "minimum": 0, "maximum": 10000} is the safest schema — no multipleOf needed. AJV and most JSON Schema validators handle integer multipleOf correctly; floating-point multipleOf requires careful end-to-end testing.
Further reading and primary sources
- RFC 8259: The JSON Data Interchange Format — IETF JSON specification — Section 6 defines JSON numbers with no precision limit
- IEEE 754-2019 Standard — Official IEEE 754 standard for floating-point arithmetic — defines double-precision format
- MDN: Number.MAX_SAFE_INTEGER — MDN reference for Number.MAX_SAFE_INTEGER, Number.isSafeInteger(), and BigInt
- decimal.js library documentation — Official Decimal.js docs — arbitrary-precision decimal arithmetic for JavaScript
- json-bigint npm package — Drop-in JSON.parse/stringify replacement that handles BigInt for large integers
- lossless-json library — JSON parser that preserves all numbers as strings and throws on unsafe conversions
Recommended reading
- Designing Data-Intensive Applications (2nd Edition) — Martin Kleppmann & Chris RiccominiThe modern classic on data systems — encoding formats, schemas, replication, and stream processing.
- JavaScript: The Definitive Guide (7th Edition) — David FlanaganThe complete reference for the language JSON came from — serialization, async, and the full standard library.
As an Amazon Associate, Jsonic earns from qualifying purchases.