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.
Fix 1: Send IDs as Strings (Recommended)
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:
| Parser | Integer type | Large integers | Notes |
|---|---|---|---|
| JavaScript (JSON.parse) | Number (double) | Loses precision above 2^53 | String IDs, BigInt reviver, json-bigint |
| Python (json.loads) | int (arbitrary precision) | Exact | parse_float=Decimal for decimals |
| Java (Jackson) | int / long / BigInteger | Exact when the field type is long or BigInteger | Map fields to long, not int or double |
| Go (encoding/json) | float64 when decoding into interface{} | Loses precision above 2^53 | Decode into int64 fields or Decoder.UseNumber() |
| C# (System.Text.Json) | long / decimal by declared type | Exact when typed | Avoid double for IDs |
| Rust (serde_json) | i64 / u64 / f64 | Exact for integers in range | arbitrary_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 withDecimalin Python,BigDecimalin 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:
| Invalid | Problem | Write instead |
|---|---|---|
01 | Leading zero | 1 |
1. | Trailing decimal point | 1.0 |
.5 | Missing leading digit | 0.5 |
+1 | Explicit plus sign | 1 |
0x1F | Hexadecimal | 31 |
NaN | Not a JSON literal | null or a string |
Infinity | Not a JSON literal | null or a string |
1_000 | Digit separators | 1000 |
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 fromcurlor 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
BIGINTcolumns 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
NaNintonull, so numeric edge cases do not survive it.