Why does JSON decoding into a Go `any` mangle a 19-digit id, and how do you keep it exact?
answer
- one wire type, many Go types
- fifty-three bits is the whole story
- give the decoder a type to aim at
- a decoder mode that defers interpretation
- the literal kept as text, parsed on demand
basics
~20 sDecoding 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 sDecoding 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 linesconst 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()) // 12345678901234567890go deeper
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.
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.
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.
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