skip to content

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?

level: seniorimportance: should knowfreq 45%

answer

  1. JSON has only one numeric type
  2. the decoder had no target type
  3. look at what the message says was there
  4. float64, not int
  5. give the decoder a struct instead

basics

~20 s

encoding/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 s

When `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 lines
text
2026/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 +0x145

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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