Skip to main content

Valid JSON, Changed Value: Which Runtimes Preserve Large Integer IDs

Nothing is wrong with the JSON. It parses, no error is raised, and the value you get back is not the value that was sent. The limit belongs to the numeric model of whatever read the document — not to JSON, which sets no bound on integer size.

The short answer

JSON puts no limit on how large an integer can be. Its grammar accepts any number of digits, and RFC 8259 only guarantees that implementations will agree on integers in the range -(2^53)+1 to (2^53)-1 — an interoperability note, not a rule about what a document may contain.

What changes the value is the consumer. JavaScript parses every JSON number into a Number, an IEEE-754 double, and a double cannot hold every integer past a certain size. The loss happens during parsing: JSON.stringify faithfully prints whatever survived, so no serializer option can recover it and no error is ever raised.

One correction worth making early, because it is repeated often: it is not true that every integer above 9007199254740991 is rounded. Plenty of larger integers survive exactly. Which ones depends on a pattern, not on a threshold.

Where JavaScript actually starts losing digits

Seven consecutive integers, each parsed and re-serialised on its own:

In the documentAfter parsingResult
90071992547409919007199254740991exact
90071992547409929007199254740992exact
90071992547409939007199254740992changed
90071992547409949007199254740994exact
90071992547409959007199254740996changed
90071992547409969007199254740996exact
90071992547409979007199254740996changed

Past 2^53 the even integers survive and the odd ones snap to a neighbour, which is why the two values on either side of 9007199254740993 come through untouched. The gap between representable integers doubles with each binary magnitude:

  • above 2^52 — step 1
  • above 2^53 — step 2
  • above 2^54 — step 4
  • above 2^55 — step 8
  • above 2^63 — step 2048

Near the top of the signed 64-bit range that step is 2,048, so a couple of thousand consecutive ids all land on the same value.

Two different ids becoming one

The practical consequence is not that a number looks slightly wrong. It is that distinguishable inputs stop being distinguishable:

// Two documents that differ in the source
const a = JSON.parse('{"id":9007199254740993}');
const b = JSON.parse('{"id":9007199254740992}');

a.id === b.id            // true
JSON.stringify(a)        // {"id":9007199254740992}
JSON.stringify(b)        // {"id":9007199254740992}

After parsing there is no information left to tell these apart. Anything downstream — an equality check, a deduplication step, a cache key, a comparison between two API responses — is working with one value where the source had two.

The same document, read by different runtimes

One fixture, parsed and re-serialised by each consumer, compared against the source text:

ValueNode JSON.parsePython jsonGo anyGo UseNumber()Go int64jq (untouched)PostgreSQL
9007199254740993changedexactchangedexactexactexactexact
12345678901234567changedexactchangedexactexactexactexact
9223372036854775807int64 maximumchangedexactchangedexactexactexactexact
18446744073709551616beyond int64changedexactchangedexacterrorexactexact
"9223372036854775807"sent as a stringexactexactexactexactexactexactexact

Python's standard library kept every tested literal, because it parses JSON integers into arbitrary-precision int rather than a float. PostgreSQL kept them too, in both json and jsonb. These are results for the values tested, not a promise about every possible numeric input.

Go gives three different answers

Go is the interesting column, because the outcome depends on what you decode into rather than on the library. The same input, the same encoding/json, three results:

// 1. Generic decoding — numbers become float64
var generic map[string]any
json.Unmarshal(data, &generic)
// 9223372036854775807 -> 9223372036854776000

// 2. UseNumber() — the literal is kept as a string
d := json.NewDecoder(r)
d.UseNumber()
// 9223372036854775807 -> 9223372036854775807

// 3. Typed int64 — exact in range, error beyond it
var typed struct{ ID int64 `json:"id"` }
json.Unmarshal(data, &typed)
// 18446744073709551616 -> cannot unmarshal number ... into Go struct field of type int64

Generic decoding is the common path and it carries exactly the same risk as JavaScript, because JSON numbers become float64. So “Go preserves large integers” is not a safe thing to assume — it preserves them when you ask it to.

jq keeps the literal until you do arithmetic

jq stores the original text of a number literal and prints it back unchanged if nothing touches it. Any arithmetic converts to a double first:

$ echo '9223372036854775807' | jq .
9223372036854775807          # literal passed through untouched

$ echo '9223372036854775807' | jq '.+0'
9223372036854776000          # arithmetic forced the conversion

The manual is explicit that arithmetic triggers the conversion, and that literal preservation depends on the build — it can be compiled out with --disable-decnum. The behaviour above was measured on jq 1.7.1; other implementations, including gojq, differ.

Why identifiers are often sent as strings

The quoted control value came through every consumer tested with its digits intact, because a string never enters a numeric model at all. That is the reason APIs handing out large ids frequently quote them.

It is worth being precise about when this applies. It suits opaque identifiers — things you look up, match, and pass along, but never add, average, or order by magnitude. It is not a general rule that large numbers belong in strings, and it is a separate question from decimal precision in monetary values, which involves a different set of trade-offs this article does not cover.

What we found in our own tools

This research started as background for a guide and turned up the same problem in DataToolsHQ. Our JSON tools were built on native JSON.parse and JSON.stringify, so they behaved exactly like the JavaScript column above: five of eleven test integers came out with different digits, and nothing on screen said so. We added safeguards before publishing this:

ToolBeforeNow
JSON FormatterReported "Valid JSON." and returned altered digits with no indication.Compares each integer literal against the source text and shows the exact before and after when the output would differ.
JSON DiffTwo documents whose ids collapsed to the same value were reported as having no differences.Refuses the comparison and explains why, rather than answering a question it can no longer answer correctly.
JSON → CSVExported changed digits into the CSV silently.Warns before you copy or download the file.

These are safeguards, not a fix for the underlying model. The tools still parse through JavaScript numbers, so the JSON Formatter is not lossless and does not claim to be — it now tells you which literals it changed and what it changed them to. The JSON Diff does not perform lossless numeric comparison either; when two ids would collapse it declines to compare rather than reporting a result it cannot stand behind.

If you want to see the effect on your own payload, paste it into the formatter and read the warning. Everything runs in your browser.

Practical rules

  1. 1. Treat the numeric model as part of the API contractA schema that says "integer" says nothing about whether the consumer can hold it. If any consumer in the chain is JavaScript, the practical ceiling is lower than the type suggests.
  2. 2. Consider strings for identifiers that are not quantitiesThe quoted id survived every consumer tested. This applies to opaque identifiers you never add, sort numerically, or compare by magnitude — not to values you compute with, and not as a blanket rule for all large numbers.
  3. 3. In Go, choose the decode target deliberatelyDecoding into any gives the same float64 behaviour as JavaScript. UseNumber() or a typed integer field keeps the digits. It is a decision, not a default.
  4. 4. Do not read a successful parse as a fidelity guaranteeNo error is raised when a value changes. Round-trip the specific values you care about rather than assuming a clean parse preserved them.
  5. 5. Count formatters and converters as consumersAnything that parses and re-emits JSON is a numeric consumer, including browser-based tools. Pasting a payload into one to tidy it up can change it.

Versions measured

Numeric handling is implementation behaviour and changes between releases. Everything above was measured on:

  • Node.js24.16.0
  • Python (stdlib json)3.9.6
  • Go (encoding/json)1.23.12
  • jq1.7.1-apple
  • PostgreSQL17.10
  • DataToolsHQ JSON Formatternative JSON.parse

The IEEE-754 behaviour underneath JavaScript is stable and will not change. Which library defaults sit on top of it can.