skip to content

A Go HTTP handler panics with a nil pointer dereference after decoding a JSON body — how do you diagnose it at 3am from the panic output?

level: seniorimportance: should knowfreq 52%

answer

  1. start from what is still running
  2. skip the frames that are not yours
  3. the decoder never complains about absence
  4. a pointer field's zero value is the culprit
  5. validate required fields after decoding

basics

~20 s

Read the topmost frame in your own code from the logged stack trace: that file and line is the dereference. The usual cause is a pointer or interface field that decoding left nil because the key was absent or null, then used without a check. Fix by validating after decode.

solid answer

~50 s

Start from the log line the `net/http` server writes — `http: panic serving <addr>: runtime error: invalid memory address or nil pointer dereference` — followed by the goroutine's stack. The server recovers panics on the connection's own goroutine, so the process is still up; that alone tells you the panic happened on the request goroutine rather than one the handler spawned. Walk down the trace past the runtime and `net/http` frames to the first frame in your module: that file:line is the dereference itself. Then ask what could be nil there. With a body of loosely specified shape, `encoding/json` reports no error for a missing or `null` key — it simply leaves the pointer, map, slice or `any` field at nil — so the handler dereferences it and panics. Reproduce with the exact body in a test, then fix by validating required fields after decode, or by using the comma-ok form when asserting out of `map[string]any`.

code

go · 12 lines
go
type payload struct {
	User *struct {
		ID string `json:"id"`
	} `json:"user"`
}

var p payload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
	http.Error(w, "bad request", http.StatusBadRequest)
	return
}
id := p.User.ID // nil dereference when "user" is absent or null

go deeper

for a junior

Be able to find the file and line in a panic's stack trace and say that a nil pointer was dereferenced there. Knowing that a decoded JSON body leaves absent fields at their zero value is the takeaway.

for a middle

Explain the mechanics: what the decoder does with a missing or null key, why the first frame in your own package is the dereference site, and why the comma-ok assertion form removes the interface-conversion variant of this bug.

for a senior

Demonstrate the incident sequence — process alive versus restarting, first frame in your module, which field can be nil, what changed in traffic — and insist the fix is required-field validation and a regression test rather than a nil check at the crash line.

for a principal

Own the contract: who guarantees the request shape, whether missing fields are a 400 by policy, and how you ensure panic reports and transport-level request failures are visible on dashboards that otherwise only count status codes.

## What the log is telling you A panic on a request goroutine does not usually crash a Go service, because `net/http`'s server runs each connection with a deferred function that intercepts panics, logs them, and closes the connection. The line you find is: ``` 2026/08/31 03:14:07 http: panic serving 10.2.0.7:41522: runtime error: invalid memory address or nil pointer dereference ``` followed by a stack trace. Two facts are already in your hands: 1. **The process is alive.** So the panic occurred on the goroutine `net/http` created for that connection — inside your handler or something it called synchronously. Had it happened on a goroutine your handler started itself, nothing would have been there to stop it and the process would have exited with status 2 instead. 2. **The client got nothing.** No response was written; the connection was closed. Whatever your dashboards show for that request is a transport-level failure, not a 500, so do not expect it in your status-code metrics. If instead you are looking at a full crash — `panic: runtime error: invalid memory address or nil pointer dereference` with `[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]` and exit status 2 — the `addr=0x0` is the confirmation that the pointer really was nil. ## Finding the dereference The trace is printed innermost-frame-first. The top frames belong to the runtime and to `net/http`; skip them. **The first frame whose package path is your module is the interesting one**, and its `file.go:63` is the exact line that dereferenced nil. That is the whole diagnosis at the mechanical level — Go's panic output points directly at the statement, which is why this failure is far cheaper to chase than a corrupted-memory bug in a language without bounds and nil checks. One caveat: heavily inlined helpers can be collapsed into the caller's frame, so the line may sit in a small function that was inlined into the one you see. ## Why decoding produced a nil in the first place This is the part worth internalising, because the trace only shows you where the program died, not why the value was nil. `encoding/json` is permissive by design: - A key **absent** from the body leaves the corresponding field untouched — at its zero value. For a `*T`, a map, a slice, an `any` or a function-typed field, that zero value is nil. - A key present with the value **`null`** likewise leaves a pointer field nil. - Extra keys in the body are ignored unless you call `Decoder.DisallowUnknownFields`. - `Decode` returns an error for malformed JSON or a type mismatch, **not** for missing data. So a handler written for the shape you expected — `p.User.ID`, or `body["user"].(map[string]any)["id"]` — panics the first time a client sends a body without that key. In practice that client is a new mobile version, a retry with a truncated body, or a partner integration; it will not be in your test fixtures. ## Fixing the class, not the line A nil check at the crash site stops tonight's page and leaves the next one intact. The durable fixes: 1. **Validate after decode.** Decode into a struct, then check every field the handler will actually rely on, returning `400` with a specific message. Required-field validation is your code's job; the decoder does not do it. 2. **Prefer value fields to pointer fields** unless you genuinely need to distinguish "absent" from "zero". A `string` cannot be nil; a `*string` can. 3. **Use the comma-ok form** for every assertion out of `map[string]any`: `u, ok := body["user"].(map[string]any)`. The single-value form panics with an interface-conversion error the moment the shape differs. 4. **Bound the body** with `http.MaxBytesReader` so a hostile or broken client cannot turn a decode into a memory problem as well. 5. **Write the failing body into a test.** The reproduction is a two-line table entry; keeping it is what stops the regression. ## What to do in the moment At 3am the order is: confirm from the log whether the process is up or restarting (that decides whether you are looking at a recovered handler panic or a full crash); pull the first frame in your code; look at the struct being decoded and identify which field can be nil; check whether the traffic pattern changed — a deploy on a caller, a new client version; and if the blast radius is wide, mitigate by rejecting the offending shape at the edge while the real fix is written and reviewed in daylight. Make sure that stack trace is actually reaching your log store. A panic report that is truncated at the first line, or lost because standard error is not collected, turns a five-minute diagnosis into an hour.

  • Why did the service stay up, and what would have been different if the panic had been on a goroutine the handler started?
    The `net/http` server runs each connection with a deferred function that intercepts panics, logs them and closes the connection, so a panic on the request goroutine costs one request. A goroutine the handler launched has no such wrapper on its stack: its panic unwinds to the top of that goroutine and the runtime exits the whole process with status 2, dropping every in-flight request.
  • What does the client actually receive when a handler panics before writing a response?
    Nothing useful — the server closes the connection, so the client sees a transport error rather than an HTTP status. That is worth knowing for monitoring: these failures do not appear in status-code metrics or access logs as 5xx, so a dashboard built only on response codes can look healthy while handlers are panicking.
  • Would `Decoder.DisallowUnknownFields` have prevented this panic?
    No. It rejects bodies containing keys your struct does not declare; this failure is the opposite case — a key your struct declares that the body omits. Absence is never an error to the decoder. Only your own validation after `Decode`, or using value fields instead of pointers where absence and zero can be treated alike, closes that gap.
  • What do you keep from the incident once the fix is merged?
    The exact body as a test case, an assertion that the handler returns 400 rather than panicking, and — if the shape came from another team — an agreement about which fields are required. Also verify the panic report reached your log store in full, since a truncated trace is what turns this five-minute diagnosis into an hour.

saying these in an interview costs you the question

  • Assumes any handler panic crashes the whole service
  • Expects the client to receive a 500 response
  • Thinks the decoder errors on a missing key
  • Reads the topmost runtime frame as the bug site
  • Adds a nil check at the crash site and stops there
  • Wraps everything in interception instead of validating input