skip to content

Why does JSON decoding into a Go `any` mangle a 19-digit id, and how do you keep it exact?

level: middleimportance: must knowfreq 58%

answer

  1. one wire type, many Go types
  2. fifty-three bits is the whole story
  3. give the decoder a type to aim at
  4. a decoder mode that defers interpretation
  5. the literal kept as text, parsed on demand

basics

~20 s

Decoding into an any makes every JSON number a float64, whose 53-bit mantissa cannot hold a 19-digit id, so it rounds silently. Use an int64 field, or a json.Decoder with UseNumber to get a json.Number.

solid answer

~50 s

Decoding into `any` gives the decoder no Go type to aim at, so it applies its fixed mapping and turns every JSON number into a `float64`. A `float64` has a 53-bit mantissa, so integers above about 9·10^15 cannot all be represented and the nearest representable value is stored instead — no error, no warning, just different digits when you print or re-encode it. There are two fixes. The precise one is to decode into a typed destination: a struct field of type `int64` or `uint64` is parsed as an integer directly, with no floating-point step, and a value that does not fit returns an `*json.UnmarshalTypeError` rather than rounding. The other, for genuinely unknown shapes, is `dec := json.NewDecoder(r); dec.UseNumber()`, which stores numbers into interface values as `json.Number` — a string type holding the original literal, with `Int64()`, `Float64()` and `String()` methods so you decide how to interpret it, and re-encoding writes the literal back unchanged.

code

go · 12 lines
go
const body = `{"id":12345678901234567890}`

var v any
json.Unmarshal([]byte(body), &v)
fmt.Printf("%T\n", v.(map[string]any)["id"]) // float64 - the exact digits are gone

dec := json.NewDecoder(strings.NewReader(body))
dec.UseNumber()
var w any
dec.Decode(&w)
n := w.(map[string]any)["id"].(json.Number)
fmt.Println(n.String()) // 12345678901234567890

go deeper

for a junior

Remember the headline: with no Go type to aim at, every JSON number lands in a float64, and float64 cannot hold every large integer exactly. Know that a typed int64 field avoids it.

for a middle

Explain the 53-bit mantissa and why the loss is silent, then demonstrate both remedies and say which you would reach for when the schema is known versus unknown.

for a senior

Describe how you would find this in a running system — printing the decoded type and value at the boundary, comparing an id end to end — and how you would stop a re-encoding proxy from corrupting numbers it never reads.

for a principal

Treat it as a contract decision: whether identifiers should be JSON strings at the estate level, given that consumers in other languages have only a double, and who pays for changing the wire format once clients exist.

## Where the digits go JSON has exactly one numeric type and no size limit in the grammar. Go has many. When you decode into a typed destination, your type resolves the ambiguity: an `int64` field means "parse this as an integer", a `float32` field means "parse this as a float". When you decode into an `any`, nothing resolves it, so the decoder falls back on `float64` for every number. A `float64` follows IEEE 754 binary64: one sign bit, eleven exponent bits and fifty-two stored mantissa bits, giving fifty-three bits of precision. Every integer up to 2^53 (about 9.007·10^15) is exactly representable; above that, the representable values thin out, and any integer between them is rounded to the nearest one. A typical 19-digit identifier lands well above that threshold, so its low digits are simply not stored. Nothing fails. The decode returns a nil error. Only when the value is printed, compared, or re-encoded does the difference show — often at a downstream system, days later. ## The typed fix The first answer is usually not an API call but a struct: ```go type Event struct { ID int64 `json:"id"` } ``` The decoder sees an integer kind and parses the literal as an integer, so all nineteen digits survive, and there is no floating-point step to lose them in. It also gets you error handling for free: a literal with a fractional part, or one too large for the field, yields an `*json.UnmarshalTypeError` naming the offending field instead of a rounded value. If the id is genuinely bigger than an `int64` — some systems use unsigned 64-bit ids — use `uint64`, and if it is bigger than that, the honest wire type is a string. ## The UseNumber fix For documents whose shape you do not know, there is a decoder mode: ```go dec := json.NewDecoder(strings.NewReader(body)) dec.UseNumber() var v any err := dec.Decode(&v) ``` With `UseNumber` set, numbers stored into interface values arrive as `json.Number` instead of `float64`. `json.Number` is defined as a named string type holding the literal exactly as it appeared in the input, and it offers three methods: - `String() string` — the original literal, unchanged - `Int64() (int64, error)` — parses it as a signed 64-bit integer, erroring if it has a fraction or overflows - `Float64() (float64, error)` — parses it as a float, with the same rounding you were avoiding, but now as your explicit choice The key property is that nothing is interpreted until you ask. A 19-digit id survives as text; you can compare it, log it, or write it back out verbatim, and you can call `Int64()` and handle the error where it is meaningful. Note that a value too large for `int64` makes `Int64()` return a range error — the number is preserved, but that particular accessor cannot represent it. ## The one API wrinkle `UseNumber` is a method on `*json.Decoder`, and there is no equivalent option on the plain `json.Unmarshal` function. If your input is already a byte slice, you wrap it — `json.NewDecoder(bytes.NewReader(data))` — and use the decoder. That is a small friction point people trip over when they try to "turn on" precise numbers for an existing `Unmarshal` call site. ## Round-tripping This matters most for code that decodes a document, changes one field and writes the rest back out. Through a plain `map[string]any`, every number in the document has been through `float64` by the time you re-encode, so large integers come back rounded and numeric formatting can change (`1.0` may reappear as `1`). With `UseNumber`, `json.Number` re-encodes as its literal text, so untouched numbers come back byte-identical. ## What to say in an interview Name the cause precisely — no Go type to aim at, so `float64`, 53 bits of mantissa — and then give both fixes with the tradeoff between them: a typed struct when you know the schema, because it is faster, self-documenting and validating; `UseNumber` when you genuinely do not, because it defers interpretation instead of destroying it. Mentioning that the failure is silent, and that you would look for it by printing `%T` and the value at the boundary rather than trusting the decode's nil error, is what separates a memorised answer from one that has been debugged.

  • What is json.Number actually, and what do its methods give you?
    It is a named string type whose value is the numeric literal exactly as it appeared in the input. `String()` returns that literal, `Int64()` parses it as a signed 64-bit integer and returns an error on a fraction or an overflow, and `Float64()` parses it as a float. Nothing is interpreted at decode time, so you choose the representation and handle the failure where it makes sense.
  • Can you get the same behaviour from json.Unmarshal without constructing a decoder?
    No. `UseNumber` is a method on `*json.Decoder`, and the plain `Unmarshal` function has no option for it. Wrap the bytes you already have — `json.NewDecoder(bytes.NewReader(data))` — call `UseNumber`, then `Decode`. That is the whole workaround, and it is why an existing `Unmarshal` call site has to be restructured slightly to gain precise numbers.
  • If the id is decoded into an int64 struct field instead, what happens when the JSON value has a fractional part?
    The decoder returns a `*json.UnmarshalTypeError` identifying the field and the offending value, rather than truncating or rounding it. It also does not abort the whole document: it leaves that field at its zero value, decodes the rest as best it can, and returns the error at the end — so you get both the partial result and a precise complaint.
  • The id fits in the JSON but not in an int64. What are the options?
    Keep it as a `json.Number` and treat it as an opaque identifier — most ids are never arithmetic. If it is genuinely unsigned 64-bit, use `uint64`. Beyond that, the wire type itself is wrong: ids larger than 64 bits should be sent as JSON strings, which also protects consumers written in languages whose only numeric type is a double.

A float64 is a ruler whose tick marks get further apart the further right you read. Near zero it can name every whole number; out past sixteen digits the ticks are wider than one, so your id is quietly filed under the nearest tick.

saying these in an interview costs you the question

  • Assumes the decoder picks int for whole numbers
  • Thinks the rounding produces an error you can check
  • Believes UseNumber is an option on json.Unmarshal
  • Calls Int64 on a json.Number without checking the error
  • Suggests parsing large ids with a regular expression instead