What Is a Nil Pointer in Go

A nil pointer in Go is a null reference that causes a runtime error when used with panic in Go 1.21+ unless the panicnil GODEBUG setting is enabled.

The empty address book

You write a function that looks up a user by ID. The database returns nothing. Your code assigns the result to a pointer variable. You forget to check if it actually found anything. The next line tries to read the user's email address. The program crashes with runtime error: invalid memory address or nil pointer dereference.

This is the classic nil pointer panic. It happens because Go treats nil differently than languages that hide memory management behind heavy abstractions. In Go, nil is not a magical nothing value that silently returns defaults. It is a concrete signal that a pointer holds no memory address. Dereferencing it means asking the CPU to read from address zero, which the operating system protects. The runtime steps in and stops the program before it corrupts memory.

What a pointer actually holds

A pointer in Go is a variable that stores a memory address. Think of it like a sticky note with a house number written on it. When you pass a pointer to a function, you are handing over the sticky note, not the house. The function can look at the house number, walk over there, and modify what is inside.

A nil pointer is a sticky note with no house number written on it. It exists as a variable, it takes up space on the stack, but it points nowhere. The zero value for every pointer type in Go is nil. If you declare a pointer without assigning it to a newly allocated value, it starts as nil.

Here is the simplest demonstration of a nil pointer in action:

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func main() {
    // Declaring a pointer without allocation leaves it at its zero value.
    var p *User

    // The variable exists, but it points to no memory location.
    fmt.Println(p == nil) // prints: true

    // Attempting to read a field through a nil pointer triggers a panic.
    // fmt.Println(p.Name) // runtime error: invalid memory address or nil pointer dereference
}

The compiler allows this code because it cannot know at compile time whether p will be assigned before use. The runtime catches the mistake the moment you try to cross the boundary.

Nil pointers are the default state. Always initialize before dereferencing.

How the runtime catches the mistake

When you write p.Name, the Go compiler translates that into a memory read operation. It takes the address stored in p, adds the offset for the Name field, and tells the CPU to fetch the bytes at that location. If p is nil, the address is zero. Modern operating systems reserve the first page of memory and mark it as unreadable. The CPU triggers a hardware fault. The Go runtime catches that fault, translates it into a panic, and prints the stack trace.

This design choice keeps Go fast. The language does not add a bounds check or a nil check on every single pointer dereference. It trusts the programmer to check before crossing. When you do check, the pattern is straightforward and explicit.

func printAge(p *User) {
    // Explicit nil check prevents the runtime panic.
    if p == nil {
        fmt.Println("no user found")
        return
    }

    // Safe to dereference because the check passed.
    fmt.Println(p.Age)
}

The community accepts this boilerplate. Writing if p == nil on every function entry that accepts a pointer is verbose by design. It makes the unhappy path visible instead of hiding it behind silent defaults or exception handling. The same philosophy applies to error handling: if err != nil is repetitive, but it forces you to acknowledge failure cases at the call site.

Check early. Return fast. Let the type system enforce the boundary.

The realistic scenario: database lookups and HTTP handlers

Pointers are most common when dealing with external data. A database query might return a row or nothing. An HTTP client might get a response or a timeout. Go represents absence with nil pointers because structs have a natural zero value that is often valid. An empty User{} struct is a perfectly valid user with a blank name and age zero. You cannot use an empty struct to mean not found. You need a separate signal. That signal is nil.

Here is how a typical HTTP handler flows with nil pointers:

func handleUser(w http.ResponseWriter, r *http.Request) {
    // Extract ID from URL path.
    id := chi.URLParam(r, "id")

    // Query returns a pointer to the found row, or nil if the row does not exist.
    u := db.GetUser(r.Context(), id)

    // Check for nil before accessing fields.
    if u == nil {
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }

    // Safe to use because the nil check passed.
    fmt.Fprintf(w, "Hello %s", u.Name)
}

The function signature communicates intent: this function might not find anything. The type system enforces the check. Notice that r.Context() is passed as the first argument to db.GetUser. Context always goes first in Go, conventionally named ctx. It carries deadlines, cancellation signals, and request-scoped values. Functions that accept a context should respect cancellation and deadlines.

Never assume a lookup succeeds. Always verify the pointer before crossing the boundary.

The interface nil trap

Pointers and interfaces interact in a way that catches almost every Go beginner. An interface value in Go is a pair: a type and a value. An interface is nil only when both the type and the value are nil. If you assign a typed nil pointer to an interface, the interface is not nil. It holds a type and a nil value.

var i interface{}
var p *User

// p is nil, so this prints true.
fmt.Println(p == nil) // true

// Assigning a typed nil pointer to an interface stores the type information.
i = p

// The interface now holds type *User and value nil.
// This prints false, which breaks naive nil checks.
fmt.Println(i == nil) // false

This happens because interfaces carry type metadata. When you assign p to i, Go records that i contains a *User. The value inside is nil, but the container itself is not. If you pass i to a function that checks if i == nil, the check fails. The function then tries to use the interface, often resulting in a panic later. The fix is to check the underlying pointer before assigning it to an interface, or to return the pointer type directly instead of wrapping it in interface{}.

The compiler will not warn you about this. It is a runtime behavior that requires understanding how interfaces store data.

Test the concrete type, not the interface wrapper. Keep interfaces at the boundaries.

The panic(nil) change in Go 1.21

Go historically allowed panic(nil). Calling panic(nil) would trigger a panic with a nil value, which made stack traces confusing and debugging painful. The runtime would print panic: <nil> and developers would spend hours tracing where the panic originated.

Starting in Go 1.21, the runtime treats panic(nil) as panic(runtime.Error("panic: nil")). It automatically wraps the nil value in a descriptive error string. This change made debugging significantly easier. If you are maintaining legacy code that relies on panic(nil) behaving silently, you can restore the old behavior, though you should not.

# Restores pre-1.21 behavior for panic(nil)
export GODEBUG=panicnil=1

You can also set it per-file using a compiler directive at the top of your source.

//go:debug panicnil=1

package main

func main() {
    // This now panics with a descriptive message by default.
    // panic(nil)
}

The compiler will warn you if you try to use this directive in newer toolchains, but it remains available for compatibility. Modern code should never rely on panic(nil). Use panic("descriptive message") or panic(errors.New("...")) instead.

Let the runtime give you a stack trace you can actually read.

When to use nil pointers versus alternatives

Go gives you several ways to represent absence or optional data. Picking the right one depends on what you are modeling and how the data flows through your program.

Use a nil pointer when you need to distinguish between a valid zero value and true absence. A *User clearly separates no user from User{}.

Use an empty slice or map when you are dealing with collections. An empty slice []string{} is easier to iterate over than a nil slice, and len() works identically. Return nil slices only when the distinction matters to the caller.

Use a boolean flag alongside a value when the zero value is ambiguous and pointers feel too heavy. The ok idiom in maps (val, ok := m[key]) is the standard pattern for this.

Use a dedicated wrapper struct when you need to carry metadata about absence. The encoding/json package uses custom unmarshalers to handle optional fields without relying on pointer nil checks everywhere.

Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next