Skip to content
Fundamentals

JSON Numbers: Why 9007199254740993 Becomes 9007199254740992

·8 min read
JSON Numbers: Why 9007199254740993 Becomes 9007199254740992 - Fundamentals guide from JSON Prettifier

Quick answer: JSON itself puts no limit on how large or precise a number can be. The limit comes from whatever parses it. JavaScript stores every number as a 64-bit IEEE 754 double, which represents integers exactly only up to 253 - 1 (9007199254740991). Anything larger is rounded silently, so 9007199254740993 becomes 9007199254740992. Send large IDs as strings, and store money as integer minor units or decimal strings.

A support ticket says "liking a post sometimes likes a different post". The IDs in the database are fine. The IDs in the API response are fine, if you read them with curl. The IDs in the browser are off by one, two, or four, depending on the number. Nothing in the stack reported an error. That is what a precision bug looks like, and it has bitten nearly every team that ever put a 64-bit ID in a JSON number.

Browse the parsed structure in the JSON Viewer and compare two sample outputs with JSON Compare. Keep the precision and duplicate-key caveats in this guide in mind when interpreting parsed results.

The Spec: One Number Type, No Size Limit

RFC 8259 section 6 defines a JSON number as an optional minus sign, digits, an optional fraction, and an optional exponent. That is the whole type. There is no integer versus float distinction, no 32 or 64-bit boundary, and no maximum length. 123456789012345678901234567890 is valid JSON.

The same section then adds the warning everyone skips: implementations that use IEEE 754 doubles are widespread, so numbers outside what a double can represent exactly are not interoperable. In other words, the format is fine; the receivers are the problem.

Why JavaScript Rounds at 2^53

A double has 64 bits: 1 sign bit, 11 exponent bits, and 52 bits of mantissa, which with an implied leading bit gives 53 bits of integer precision. Every integer from 0 to 253 is exact. Above that, the gaps open up: between 253 and 254 only even numbers exist, then multiples of 4, and so on.

Number.MAX_SAFE_INTEGER;        // 9007199254740991
JSON.parse("9007199254740992");  // 9007199254740992  (2^53, exact)
JSON.parse("9007199254740993");  // 9007199254740992  (rounded down)
JSON.parse("9007199254740995");  // 9007199254740996  (rounded up)

JSON.parse("1234567890123456789"); // 1234567890123456800
Number.isSafeInteger(1234567890123456800); // false

No exception, no warning. Number.isSafeInteger() is the only built-in hint, and it tells you the number might have been rounded, not what the original was. The JSON.parse and JSON.stringify guide lists the other silent conversions; this one is the costliest.

The Classic Bug: Snowflake IDs

Twitter introduced 64-bit "snowflake" IDs around 2010, and the first thing that happened was JavaScript clients corrupting tweet IDs. The fix, which is still in their API today, was to add an id_str field next to id. Discord's API, which also uses snowflakes, documents that IDs are serialized as strings for the same reason. Any service that uses database bigint keys, UUID-as-integer, or timestamp-prefixed IDs hits the same wall.

// What the server sends
{"id": 1301234567890123456, "id_str": "1301234567890123456"}

// What a browser sees after JSON.parse
{ id: 1301234567890123500, id_str: "1301234567890123456" }

If your IDs come from a database BIGINT or SERIAL8 column, assume they will cross 253 eventually, and fix the format before they do.

The simplest fix is the right one. An ID is an identifier, not a quantity; nobody adds two user IDs together. Quote it on the way out of the server and every client, in every language, gets the exact value:

// Node.js, before res.json()
const payload = { id: String(row.id), name: row.name };

// Python
import json
json.dumps({"id": str(row.id), "name": row.name})

Do this in the serialization layer, not in individual handlers, so it cannot be forgotten. Document it in the schema: in JSON Schema that is {"type": "string", "pattern": "^[0-9]+$"}, and OpenAPI has the format: int64 hint for the cases where you keep numbers but want generators to use a 64-bit type.

Fix 2: BigInt in JavaScript (and Its Limits)

JavaScript has had BigInt for years, but JSON.parse does not produce it and JSON.stringify refuses to consume it:

JSON.stringify({ id: 9007199254740993n });
// TypeError: Do not know how to serialize a BigInt

// Workaround on the way out: convert to string in a replacer
JSON.stringify({ id: 9007199254740993n }, (k, v) =>
  typeof v === "bigint" ? v.toString() : v
);
// {"id":"9007199254740993"}

On the way in, a plain reviver is too late; by the time it runs, the number has already been rounded. Engines that implement the JSON.parse source text access proposal fix this by passing a third argument that carries the raw text of the value. Recent V8 versions support it; check your target before relying on it:

const text = '{"id": 9007199254740993}';

JSON.parse(text, (key, value, context) => {
  if (key === "id" && typeof value === "number" && context?.source) {
    return BigInt(context.source);
  }
  return value;
});
// { id: 9007199254740993n }

Where context is unavailable, the json-bigint package parses the text itself and returns native BigInts when asked:

import JSONbig from "json-bigint";
const parse = JSONbig({ useNativeBigInt: true }).parse;

parse('{"id": 9007199254740993}').id; // 9007199254740993n

Both approaches share a downside: BigInt and Number do not mix. 1n + 1 throws, and every downstream function that expects a Number has to be checked. String IDs avoid all of that.

How Other Languages Parse Large Integers

This is not a universal problem, which is why it surprises back-end developers:

ParserInteger typeLarge integersNotes
JavaScript (JSON.parse)Number (double)Loses precision above 2^53String IDs, BigInt reviver, json-bigint
Python (json.loads)int (arbitrary precision)Exactparse_float=Decimal for decimals
Java (Jackson)int / long / BigIntegerExact when the field type is long or BigIntegerMap fields to long, not int or double
Go (encoding/json)float64 when decoding into interface{}Loses precision above 2^53Decode into int64 fields or Decoder.UseNumber()
C# (System.Text.Json)long / decimal by declared typeExact when typedAvoid double for IDs
Rust (serde_json)i64 / u64 / f64Exact for integers in rangearbitrary_precision feature for larger

Python is the standout. Its int has no upper bound, so json.loads("9007199254740993") is exact, and the json module even lets you swap the float parser with parse_float=decimal.Decimal. That is also why a Python service can emit IDs that break a JavaScript front end without anyone on the Python side noticing: both ends are correct in their own runtime. Go has the opposite trap: decoding into map[string]interface{} yields float64, and the fix is typed structs or decoder.UseNumber().

Floats: 0.1 + 0.2 and Money

Integers above 253 are the dramatic case. The quiet case is any decimal fraction, because doubles are binary and most decimal fractions have no exact binary form:

JSON.parse("0.1") + JSON.parse("0.2"); // 0.30000000000000004
JSON.parse("19.99") * 3;                 // 59.97 (prints fine, but is 59.969999999999999)
0.1 + 0.2 === 0.3;                       // false

This is not a JSON bug and no parser setting fixes it; it is how doubles work in every language that uses them. For money, there are two honest formats:

  • Integer minor units. {"amount": 1999, "currency": "USD"} means $19.99. Stripe's API uses this convention. Integers below 253 are exact, sums are exact, and the currency tells you where the decimal point goes.
  • Decimal strings. {"amount": "19.99"}. Parse with Decimal in Python, BigDecimal in Java, or a decimal library in JavaScript. This is the better choice when amounts have more than two decimal places, such as exchange rates or crypto quantities.

Whichever you choose, apply it to every amount field in the API, not just the ones that caused the first bug.

Number Syntax That Is Not Valid JSON

Precision is one way numbers go wrong. The other is syntax. JavaScript accepts all of these as literals; JSON rejects every one:

InvalidProblemWrite instead
01Leading zero1
1.Trailing decimal point1.0
.5Missing leading digit0.5
+1Explicit plus sign1
0x1FHexadecimal31
NaNNot a JSON literalnull or a string
InfinityNot a JSON literalnull or a string
1_000Digit separators1000

Exponents are fine (1e5, 2.5E-3), and so is -0. A leading zero is the one people trip on most, usually from zero-padded codes like 007 that should have been strings anyway. Pasting the document into JSON Prettifier will point at the exact line and column for any of these; the error text from JSON.parse is usually Unexpected number in JSON or Unexpected token, which our JSON parse error guide decodes.

Common Mistakes

  • Checking the bug in a JavaScript tool. Any browser-based formatter that parses with JSON.parse, including ours, displays the rounded value, because the rounding happens at parse time. Compare the raw text from curl or the Network tab's Response view with the formatted output. If they differ in the last digits, you have found the problem.
  • Casting on the client. Number(id_str) reintroduces the exact bug you avoided. Keep IDs as strings all the way to the comparison or the URL.
  • Fixing only the read path. If the client sends {"id": 1301234567890123500} back in a PUT, the server now updates the wrong row. Both directions need the string form.
  • Trusting a database driver. Some Node drivers return BIGINT columns as strings by default, some as Numbers, and some let you choose. Check the option rather than assuming.
  • Deep-cloning with JSON. The JSON.parse(JSON.stringify()) idiom throws on BigInt and turns NaN into null, so numeric edge cases do not survive it.

Frequently Asked Questions

What is the largest number JSON can hold?
The JSON grammar sets no limit; a number can have as many digits as you like. The limit comes from the parser. JavaScript stores every number as a 64-bit double, so integers are exact only up to 9007199254740991 (Number.MAX_SAFE_INTEGER). Python parses integers exactly at any size.
Why does JSON.parse change 9007199254740993 to 9007199254740992?
9007199254740993 is 2^53 + 1. A double has 53 bits of integer precision, so above 2^53 it can only represent every second integer. The parser rounds to the nearest representable value, which is 9007199254740992, and there is no error because rounding is normal floating-point behavior.
Should IDs in JSON be strings or numbers?
Strings, if the ID can ever exceed 2^53 or is not meant for arithmetic. Twitter, Discord, and most services that use 64-bit snowflake IDs send them as strings for this reason. A string survives every parser unchanged; a number is only safe in parsers that keep integers exact.
Can JSON.parse return a BigInt?
Not by default. A reviver function sees the value only after it has been rounded to a double, so it is too late. Engines that implement the JSON.parse source text access proposal pass a third context argument with the raw source text, which lets you call BigInt(context.source). Otherwise use the json-bigint package.
Why is NaN not valid in JSON?
The JSON grammar only allows decimal digits with an optional fraction and exponent, so NaN, Infinity, and -Infinity are not numbers in JSON. JSON.stringify writes them as null, and Python's json.dumps writes them as the literals NaN and Infinity unless allow_nan=False, which produces JSON that other parsers reject.
How should I store money in JSON?
Either as an integer in the smallest currency unit (cents, paise) or as a decimal string such as "19.99". Both avoid binary floating-point rounding. Never use a JSON float for amounts that will be summed or compared.

Catch Invalid Number Syntax Instantly

Leading zeros, NaN, and stray plus signs are flagged with a line and column. Paste, validate, fix. Nothing is uploaded.

Validate My JSON