Saturday, August 15, 2026
nil Pointer Dereference in Go: How to Actually Debug It
Posted by
nil Pointer Dereference in Go: How to Actually Debug It
The runtime panicked because some *T was nil and the code followed it: a field read, a write, or a method call. The stack trace names the line. Start there. Do not wrap the call in recover and keep going — that hides the bug. Find which pointer is nil and why it was never set.
The message is always:
panic: runtime error: invalid memory address or nil pointer dereference
That is an implicit panic from the runtime, same unwind as panic(value): deferred calls run, then the process dies unless something recovers.
The failure
package main
import "fmt"
type Cache struct {
Hits int
}
type Service struct {
Cache *Cache
}
func (s *Service) Hit() {
s.Cache.Hits++ // panics if Cache is nil
}
func main() {
var s Service // Cache is nil
s.Hit()
fmt.Println(s.Cache.Hits)
}
panic: runtime error: invalid memory address or nil pointer dereference
goroutine 1 [running]:
main.(*Service).Hit(...)
/tmp/main.go:15
main.main()
/tmp/main.go:20
The first your frame is Hit at line 15. s is not nil — s.Cache is.
Why this happens
A pointer's zero value is nil. nil is not an empty struct. Following it is not defined, so the runtime panics.
The usual sources:
var p *Tor a struct field never assigned. Constructors that returnService{}instead of&Service{Cache: &Cache{}}.- A function that returns
nilon "not found".user := find(id); fmt.Println(user.Name)without checking. - Map lookup of
*T. Missing key →nil. Thenusers["x"].Namepanics. - Embedded
*Innerthat is nil.o.Valuepromotes too.Inner.Value. The outer struct is fine; the inner pointer is not. - A typed nil inside an interface.
var p *ConsoleLogger; var l Logger = p; if l != nil { l.Log(...) }still panics. The interface is not nil (it has a type); the concrete pointer is.
Method calls on a nil receiver are legal if the method does not dereference the receiver. func (s *Service) Hit() still runs; the panic is the .Cache access inside.
How to debug it
- Read the stack from the top. Skip
runtime.frames. The firstpackage.functionin your module is the dereference. Open that file:line. - Name the pointer. On that line, every
.and*is a candidate. Print or inspect each with Delve (dlv debug,p s,p s.Cache) or a one-linefmt.Printf("%#v\n", s). - Walk backward to the assignment. Who was supposed to set it? A constructor, a JSON unmarshal, a test fixture, a map? Unmarshal of missing JSON object fields leaves pointers nil.
- Widen the trace if the panic is in a library.
GOTRACEBACK=allprints every goroutine. A panic in a worker often started from a nil you passed in at spawn time. - Fix the construction, not the symptom. Initialize the field, return an error instead of a nil pointer, or check
if p == nilat the API boundary that produces it.
func NewService() *Service {
return &Service{Cache: &Cache{}}
}
func (s *Service) Hit() {
if s == nil || s.Cache == nil {
return // or panic with a useful message, or return error
}
s.Cache.Hits++
}
Guarding every field is noise. Prefer types that cannot be used half-built: a constructor that always sets Cache, tests that call that constructor, and go vet / nilness analyzers for the rest.
recover belongs at goroutine boundaries in servers so one bad request does not kill the process. It is not how you debug this. After recover, log the stack and treat it as a bug to fix.
Learn more: