serpentiel

You already know JavaScript can’t be trusted with a large integer. You’ve seen JSON.parse hand back the wrong number, you reached for BigInt, you filed it under “JavaScript being JavaScript” and moved on. You blamed the wrong noun. It was never a JavaScript problem — it’s a float64 problem, and Go has float64 too. Decode a JSON number into any and Go turns it into a float64 that can’t hold it — silently, with none of the reputation to warn you. The scope is the whole point: this happens only through any. Hand the decoder a typed int64 and Go is exact. The entire bug is a choice of type, and the dangerous choice is the convenient one.

Here is the part everyone accepts about JavaScript:

JSON.parse('{"id":9223372036854775807}').id // 9223372036854776000

Every number in JavaScript is a float64 (an IEEE-754 double), so there is nowhere else for the value to go. The language earned its reputation honestly. But the reputation taught the wrong lesson: people learned “JavaScript is bad with numbers” instead of “doubles can’t hold big integers,” and carried the first lesson into languages where the second one still applies.

Go is a typed language with a real int64, which is exactly what makes it dangerous here — it feels safe. Decode into a struct field of type int64 and you are safe; the parser aims for the target you named. But decode into any, or a map[string]any, or anything you reach for when the payload is dynamic, and encoding/json picks the type itself. For a number, it picks float64.

var v any
json.Unmarshal([]byte(`9223372036854775807`), &v)
fmt.Println(v) // 9.223372036854776e+18 — i.e. 9223372036854776000, not your 807

The Go program looks nothing like the JavaScript one and does the exact same damage — but it draws none of the suspicion, because “Go is good with numbers” holds right up until you hand the decoder an any and let it pick the type.

2^53 (9007199254740992) is the line. Every integer up to it is exact; the very next one, 2^53 + 1, already isn’t — it rounds straight back to 2^53. Past the line a double stops holding every integer and starts skipping, and the gaps grow as the numbers grow. 9223372036854775807 — where a signed 64-bit integer maxes out, the ceiling of a Go int64 or a database bigint — sits so far past the line it drops four digits at once. That is the number in every example here: not a corner case, just int64’s front door. Snowflake IDs and time.UnixNano live out here too.

Paste one in and watch both decodes at once — any drops the tail, int64 holds it. The arithmetic runs in your browser, but a browser number and a Go any are the same IEEE-754 double, so the any row is byte-for-byte what your Go service gets back. Don’t trust the browser? Run it in real Go.

type an integer
9223372036854775807

you sent 9223372036854775807

into any 9223372036854776000

into int64 9223372036854775807 intact

the any lost the tail; the int64 did not — the type you decode into is the whole difference

And a typed int64 only guards your side of the wire. The moment that value crosses back into JSON for a float64 consumer downstream — a browser, another service decoding into any — it’s gone again. The weakest link decides, and it’s usually the one you didn’t write.

The rule is one line: never let a large integer touch a float64.

  • Decode into a typed integer, not a dynamic one. An int64 field makes the parser aim right; encoding/json into any is where it goes wrong. If you must stay dynamic, json.Decoder with UseNumber() keeps the digits as text.
  • Send large integers as strings on the wire. This is why Twitter added id_str back in 2011 — for this exact bug — why Stripe ids are strings, why Discord snowflakes arrive quoted. A string has no precision to lose, and it survives a consumer you’ll never meet.

It reaches further than a stray API call. I filed terraform-plugin-sdk#1215 after a Terraform provider crashed reading a value it had written itself: a legacy layer funneled a database default through a float64, wrote 9223372036854776000 into state, and panicked on the next plan parsing its own corrupted output back into an integer. It never failed on write — only on the read after, because write is where the poison goes in and read is where it’s swallowed. Still open, untouched since 2023.

A float64 is a 64-bit box that spends eleven of those bits on an exponent. It is not an integer type. Treat it like one and the numbers you can’t afford to lose are exactly the ones it drops.

comments