A webhook handler asserts payload["retry_count"].(int) on JSON decoded into map[string]any and the log shows "interface conversion: interface {} is float64, not int" — why, and what do you change?
answer
- JSON has only one numeric type
- the decoder had no target type
- look at what the message says was there
- float64, not int
- give the decoder a struct instead
basics
~20 sencoding/json decodes every JSON number into float64 when the target is map[string]any, so an assertion to int never matches and the one-result form panics. Use the comma-ok form on float64 and answer 400, or decode into a typed struct.
solid answer
~50 sWhen `encoding/json` unmarshals into `any` it has no target type to guide it, so it uses its documented defaults: numbers become `float64`, objects `map[string]interface{}`, arrays `[]interface{}`, strings `string`, booleans `bool`, and JSON null a nil interface. `payload["retry_count"]` therefore holds a `float64`, the assertion to `int` fails, and because the one-result form was used the failure is a panic rather than a false boolean. The panic message names both types, and the first frame in your own package points at the exact line. The service stays up — `net/http` recovers a panic in a handler goroutine, logs the trace and drops that connection — but the caller sees a broken response and gets nothing useful back. The immediate fix is the comma-ok form on `float64` plus a 400 for anything else; the durable fix is to decode into a struct with an `int` field so the decoder does the checking and returns a typed error.
code
text · 6 lines2026/09/02 03:14:07 http: panic serving 10.1.4.9:52344: interface conversion: interface {} is float64, not int
goroutine 42 [running]:
net/http.(*conn).serve.func1()
panic({0x6c1a80?, 0xc0000b4030?})
main.handleWebhook(...)
/app/webhook.go:37 +0x145go deeper
Be ready to read the panic message and name the two types in it, and to say that the comma-ok form would have avoided the crash by reporting failure through a boolean instead.
Explain the decoder's defaults for an any target — numbers to float64, objects to map[string]interface{}, null to a nil interface — and why the assertion could therefore never have succeeded.
Walk the whole diagnosis at 3am: what the log line proves, why the process is still up, which request failed, and then argue for decoding into typed structs so the assertion disappears rather than gets a guard.
Own the rule for the codebase: where untyped payloads are allowed to exist, who validates at the edge, and whether a recovery middleware is a backstop or an excuse. Weigh the migration cost of typing every webhook payload.
## Reading the panic ``` http: panic serving 10.1.4.9:52344: interface conversion: interface {} is float64, not int ``` The runtime tells you three things: the operation was a type assertion, the interface actually held a `float64`, and the code asked for an `int`. The stack below it has `net/http`'s recover wrapper at the top, the `panic` frame, and then the first frame in your own package with a file and line — that line is the assertion. There is nothing else to diagnose; the message is self-contained, which is one genuine argument in favour of the panicking form in code where the type really is guaranteed. ## Why float64 JSON has one numeric type. When you unmarshal into a `map[string]any`, the decoder has no Go type to steer by, so it applies its documented defaults for storing into an interface value: | JSON | Go dynamic type | |---|---| | number | `float64` | | string | `string` | | boolean | `bool` | | object | `map[string]interface{}` | | array | `[]interface{}` | | null | nil interface | So `7` and `7.0` are indistinguishable after decoding, and an assertion to `int` can never succeed no matter what the sender wrote. The bug is not intermittent in the interesting sense — it fires for *every* number — which usually means the panicking line was reached for the first time by a payload variant that had never been exercised, for example an optional field that only recent senders include. Two related traps live next to this one. A **missing** key returns the zero value of the map's element type, which for `map[string]any` is a nil interface; the one-result form then panics with `interface conversion: interface is nil, not float64`. And a large integer round-tripped through `float64` loses precision beyond 2^53, which matters for 64-bit ids sent as JSON numbers. ## What the server did `net/http` runs each connection in its own goroutine and recovers panics from your handler, logs `http: panic serving …` with the stack, and closes that connection. The process survives, so this shows up as a single ugly log line and one failed request, not as a restart — which is exactly why it can sit unnoticed until a 3am page about a webhook sender's retries. Do not mistake that safety net for handling: the client got no status code it can act on, and if the panic happens after you have started writing the response the caller sees a truncated body. The safety net is also narrower than it looks. It only covers panics on the goroutine serving the request. If the handler spawns a goroutine and that one panics, nothing recovers it and the whole process dies. ## The fixes, in order of durability **1. Comma-ok with a real error path.** The minimum change: assert to `float64`, test the boolean, and answer 400 with a message naming the field. Untrusted input never justifies the one-result form. ```go n, ok := payload["retry_count"].(float64) if !ok { http.Error(w, "retry_count must be a number", http.StatusBadRequest) return } ``` **2. Decode into a struct.** Give the decoder a target type and it does the checking for you: ```go var payload struct { RetryCount int `json:"retry_count"` } ``` Now `7.5` produces a `*json.UnmarshalTypeError` from `Decode` instead of a panic somewhere later, the field is an `int` everywhere downstream, and no assertion appears in the handler at all. This is the answer a reviewer wants: the assertion disappears rather than getting a guard. **3. `Decoder.UseNumber()`** when the payload genuinely has unknown shape and you must keep integer precision. Numbers then decode as `json.Number`, a string type with `Int64()` and `Float64()` methods, so you can parse exactly and report a parse error instead of guessing. **What not to do:** wrapping the handler in `defer recover()` and calling it fixed. That converts a panic into a 500 while leaving the code asserting a type the data never has; the request still fails, and you have hidden the message that told you why. A recovery middleware is a reasonable last-resort backstop for the whole server, not a substitute for validating input at the edge. ## The habit to take away At any boundary carrying data you did not construct, values of type `any` should be converted into concrete, checked types immediately, and the one-result assertion form should not appear at all. Reserve it for invariants you can see being established a few lines above — and even then, a comma-ok with a clear error costs one extra line.
- What would the panic message be if the key were simply absent from the map?`interface conversion: interface is nil, not float64`. A lookup on a `map[string]any` returns the element type's zero value for a missing key, which is a nil interface, and a nil interface matches no assertion. So the one-result form panics on absence as readily as on a wrong type — another reason the comma-ok form belongs at a decoding boundary.
- How does decoding into a struct with an int field change the failure?The decoder does the type check itself and `Decode` returns a `*json.UnmarshalTypeError` naming the offending value and target type. You handle it as an ordinary error and answer 400, no assertion is written at all, and downstream code receives a real `int` instead of an `any` every layer has to re-check.
- Why is adding defer/recover to the handler not the fix here?Recovery converts the crash into a 500 but leaves the code asserting a type the payload never contains, so the request still fails and the diagnostic message is now swallowed. Validation belongs at the edge; a recovery middleware is a backstop for unforeseen panics, and it cannot help at all if the panic happens on a goroutine the handler started.
- When would you reach for Decoder.UseNumber instead?When the shape genuinely is unknown and precision matters — 64-bit ids sent as JSON numbers lose exactness above 2^53 once they pass through float64. `UseNumber` makes numbers decode as `json.Number`, a string type with `Int64` and `Float64` methods, so you parse deliberately and report a parse error rather than silently rounding.
saying these in an interview costs you the question
- Assumes JSON numbers decode into int
- Adds defer recover instead of validating input
- Claims one handler panic crashes the whole server
- Uses the one-result form on user-controlled data
- Thinks a missing map key returns an error