Der Zero Value eines Pointers ist nil — eine Pointer-Variable, die du deklarierst, aber nicht initialisierst, zeigt auf nichts. Das ist gleichzeitig die häufigste Crash-Quelle in Go-Programmen und ein idiomatisches Werkzeug für optionale Werte: „kein Parent", „kein nächstes Element", „kein Wert gesetzt". Dieser Artikel zeigt, was beim Dereferenzieren eines nil-Pointers wirklich passiert, wann Defensive Checks sinnvoll sind und wann sie zum Code-Smell werden, warum Methoden mit nil-Receiver in Go absichtlich legal sind, und vor allem die berüchtigte typed-nil-Interface-Falle, an der jeder Go-Entwickler einmal hängen bleibt.

nil als Zero Value

Die Go-Spec ist hier knapp und eindeutig:

The value of an uninitialized pointer is nil.

Und im Abschnitt zu den vordefinierten Identifiern:

The predeclared identifier nil is the zero value for types whose values can contain references.

Konsequenz: Wer eine Pointer-Variable mit var ohne Initialisierer anlegt, bekommt automatisch nil. Das gilt für Top-Level-Variablen, für lokale Variablen in Funktionen und für Felder einer frisch erzeugten Struct-Instanz:

Go zero-value.go
package main

import "fmt"

type Config struct {
    Host    string
    Logger  *Logger   // Pointer-Feld — Zero Value ist nil
}

type Logger struct{ Prefix string }

func main() {
    var p *int           // lokal — nil
    fmt.Println("p:", p)

    c := Config{Host: "localhost"} // Logger nicht gesetzt
    fmt.Println("c.Logger:", c.Logger)

    // Auch &Config{} initialisiert nicht gesetzte Pointer-Felder mit nil
    cp := &Config{}
    fmt.Println("cp.Logger:", cp.Logger)
}
Output
p: <nil>
c.Logger: <nil>
cp.Logger: <nil>

Das ist die gleiche Mechanik wie bei jedem anderen Zero Value (0, "", false) — nur dass nil für Pointer, Interfaces, Slices, Maps, Channels und Funktions-Werte gilt. Wichtig: nil ist kein eigener Typ und nicht ohne Typ-Kontext deklarierbar — var n = nil ist Compile-Fehler, weil der Compiler nicht weiß, welche Art von nil gemeint ist.

Der klassische Crash — Dereferenzierung paniciert

Ein nil-Pointer zeigt nirgendwohin. Wer ihn dereferenziert — also *p schreibt oder per p.Feld indirekt zugreift — produziert zur Laufzeit einen Panic mit einer charakteristischen Meldung:

Go nil-deref.go
package main

type Config struct{ Host string }

func main() {
    var c *Config        // nil
    _ = c.Host            // panic
}
Output
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=...]

Drei Punkte, die du im Hinterkopf haben solltest:

  • Auch p.Feld ist eine Dereferenzierung. Der Selektor-Ausdruck p.Feld ist äquivalent zu (*p).Feld — Go macht das implizite Dereferenzieren nur lesbarer. Wenn p == nil, paniciert der Zugriff genauso.
  • fmt.Println(p) paniciert nicht. Den Pointer selbst auszugeben ist sicher — er wird als <nil> formatiert. Erst der Zugriff durch den Pointer kracht.
  • Der Panic ist nicht abfangbar wie eine Exception. Du kannst ihn mit recover() in einem defer einfangen, aber das ist eine Notbremse — kein Kontrollfluss. Idiomatisch ist, gar nicht erst zu dereferenzieren, wenn der Pointer nil sein könnte.

Defensive Checks — wann nötig, wann übertrieben

Die naheliegende Antwort auf nil-Pointer-Panics: vor jedem Zugriff prüfen. Das ist in vielen Fällen richtig — aber nicht in allen. Übertriebene Defensive Checks sind selbst ein Code-Smell, weil sie suggerieren, dass der Funktions-Vertrag unklar ist.

Go defensive.go
package main

import "fmt"

type User struct{ Name string }

// Findet einen User — gibt nil zurück, wenn nichts da ist
func findUser(id int) *User {
    if id == 1 {
        return &User{Name: "Alice"}
    }
    return nil
}

func printName(u *User) {
    if u == nil {
        fmt.Println("(kein User)")
        return
    }
    fmt.Println(u.Name)
}

func main() {
    printName(findUser(1)) // Alice
    printName(findUser(2)) // (kein User)
}
Output
Alice
(kein User)

Die Faustregel für Defensive Checks:

  • Prüfe immer, wenn der Pointer aus einer Quelle stammt, die nil zurückgeben darf. Map-Lookups (m[key] gibt Zero Value bei nicht-existentem Key — bei Pointer-Values also nil), Lookup-Funktionen, optionale Felder in Configs.
  • Prüfe nicht, wenn der Funktions-Vertrag nil ausschließt. func New() *Config wird typischerweise garantiert nicht-nil zurückgeben. Wer hier trotzdem prüft, signalisiert: „Ich vertraue dem API nicht." Das ist Rauschen.
  • Prüfe am Eintritt einer Funktion, die Pointer als Parameter nimmt, nur, wenn die Funktion mit nil sinnvoll umgehen soll. Wenn nil ein Bug ist, lass es paniciern — der Stack-Trace ist die ehrlichste Fehler-Meldung.

go vet und nilness (Teil von golangci-lint) erkennen einige offensichtliche nil-Dereferenzierungen statisch — aber nicht alle. Verlass dich nicht ausschließlich darauf.

Eine in C# oder Java unmögliche Sache: In Go darf eine Methode mit Pointer-Receiver auf einem nil-Receiver aufgerufen werden — solange sie den Receiver nicht dereferenziert. Der Methoden-Aufruf ist syntaktischer Zucker für einen normalen Funktions-Aufruf, bei dem der Receiver das erste Argument ist; ob das Argument nil ist, prüft die Runtime nicht.

Das Standard-Beispiel ist eine Linked-List-Methode, die rekursiv über nil als Abbruch-Bedingung läuft:

Go nil-receiver.go
package main

import "fmt"

type Node struct {
    Value int
    Next  *Node
}

// Length darf auf einem nil-Receiver aufgerufen werden —
// nil bedeutet leere Liste, Länge 0.
func (n *Node) Length() int {
    if n == nil {
        return 0
    }
    return 1 + n.Next.Length()
}

// String formatiert die Liste — funktioniert auch auf leerer Liste
func (n *Node) String() string {
    if n == nil {
        return "[]"
    }
    return fmt.Sprintf("[%d]->%s", n.Value, n.Next.String())
}

func main() {
    var empty *Node           // nil
    fmt.Println(empty.Length()) // 0 — kein Panic!
    fmt.Println(empty.String()) // []

    list := &Node{1, &Node{2, &Node{3, nil}}}
    fmt.Println(list.Length()) // 3
    fmt.Println(list.String()) // [1]->[2]->[3]->[]
}
Output
0
[]
3
[1]->[2]->[3]->[]

Was hier passiert: empty.Length() ruft die Funktion auf, der Receiver-Wert ist nil, aber die Methode prüft if n == nil als allerersten Schritt. Erst der Zugriff auf n.Next oder n.Value würde paniciern — und genau diese Stelle wird durch den Check geschützt.

Diese Technik macht „leere Liste = nil-Pointer" zu einer eleganten Repräsentation: keine Sonderbehandlung für „die Liste existiert noch nicht", weil nil sich wie eine reguläre leere Liste verhält. Voraussetzung: Jede Methode des Typs muss den nil-Fall sauber behandeln. Wer eine Methode ohne nil-Check hinzufügt, bricht die Invariante.

Die typed-nil-Interface-Falle

Das ist die Falle, an der jeder Go-Entwickler einmal hängen bleibt — der berühmte Eintrag aus den Go-FAQ. Die Kurzform: Ein Interface-Wert ist nur dann nil, wenn sowohl der Typ als auch der Wert unset sind. Ein Interface, das einen nil-Pointer eines konkreten Typs enthält, ist nicht nil.

Die FAQ formuliert es so:

An interface value is nil only if the V and T are both unset. If we store a nil pointer of type *int inside an interface value, the inner type will be *int regardless of the value of the pointer: (T=*int, V=nil). Such an interface value will therefore be non-nil even when the pointer value V inside is nil.

Im Code wird das gefährlich, weil die Falle still ist — keine Compile-Warnung, kein Panic, nur logischer Schaden:

Go typed-nil.go
package main

import "fmt"

type MyError struct{ Msg string }

func (e *MyError) Error() string { return e.Msg }

// FALSCH: gibt *MyError zurück, der in error verpackt wird
func mayFailBad(ok bool) error {
    var e *MyError       // e ist nil
    if !ok {
        e = &MyError{Msg: "kaputt"}
    }
    return e             // *MyError landet im error-Interface
}

// RICHTIG: gibt explizit nil als error zurück
func mayFailGood(ok bool) error {
    if !ok {
        return &MyError{Msg: "kaputt"}
    }
    return nil           // untyped nil
}

func main() {
    err1 := mayFailBad(true)
    fmt.Println("bad  err == nil:", err1 == nil)   // false (!)

    err2 := mayFailGood(true)
    fmt.Println("good err == nil:", err2 == nil)   // true
}
Output
bad  err == nil: false
good err == nil: true

Warum: Ein Interface-Wert besteht intern aus zwei Slots — dem dynamischen Typ und dem dynamischen Wert. In mayFailBad wird e (Typ *MyError, Wert nil) in den Rückgabewert vom Typ error zugewiesen. Das Interface speichert (T=*MyError, V=nil) — der Typ ist gesetzt, also ist das Interface nicht nil. Der Aufrufer prüft err != nil, kriegt true, denkt „da kommt ein Fehler" und führt Fehlerbehandlung aus, obwohl der innere Pointer nil ist.

Die Fix-Regel ist einfach: Gib error nicht über eine Pointer-Variable des konkreten Typs zurück, sondern entscheide explizit. Entweder du gibst nil zurück (untyped), oder du gibst einen nicht-nil konkreten Error zurück. Niemals einen nil-Pointer eines konkreten Error-Typs.

nil-Vergleich

Pointer sind mit == und != vergleichbar — entweder mit nil oder untereinander. Die anderen Referenz-Typen (Map, Slice, Channel, Funktion) haben jeweils ihren eigenen nil-Wert, sind aber nicht untereinander vergleichbar:

Go nil-compare.go
package main

import "fmt"

func main() {
    var p *int
    var s []int
    var m map[string]int
    var ch chan int
    var f func()

    // Jeder einzeln gegen nil — alles erlaubt
    fmt.Println(p == nil, s == nil, m == nil, ch == nil, f == nil)

    // FEHLER: nil-Slice mit nil-Map vergleichen — nicht erlaubt
    // fmt.Println(s == m)
    // FEHLER: Slices sind generell nicht mit == vergleichbar
    // fmt.Println(s == s)
}
Output
true true true true true

Wichtige Details, die in der Praxis stolpern lassen:

  • <, >, <=, >= sind auf Pointern nicht definiert. Nur Gleichheit. Pointer sortieren ergibt sowieso selten Sinn.
  • fmt.Println(p) zeigt <nil> an — das ist kein String-Vergleich, sondern die Formatierungs-Konvention.
  • Reflect-basierte Checks sind langsam. reflect.ValueOf(x).IsNil() funktioniert auch hinter Interfaces, kostet aber Performance. Im Hot Path lieber Typ-Assertion plus expliziter nil-Check.

nil als gültiger Wert

nil ist nicht nur „Fehler-Wert" oder „uninitialisiert". In vielen idiomatischen Patterns ist nil ein bedeutungstragender Zustand:

Go nil-as-value.go
package main

// (1) Optionale Felder in Configs
type ServerConfig struct {
    Host string
    Port int
    TLS  *TLSConfig // nil = kein TLS, sonst konfiguriert
}

type TLSConfig struct {
    CertFile string
    KeyFile  string
}

// (2) "Kein Parent" in Tree-Strukturen
type TreeNode struct {
    Value    int
    Parent   *TreeNode // nil = Wurzel
    Children []*TreeNode
}

// (3) "Kein nächster" in Linked Lists
type ListNode struct {
    Value int
    Next  *ListNode // nil = Ende der Liste
}

// (4) "Noch nicht berechnet" für Lazy-Init
type LazyValue struct {
    cached *Result   // nil = noch nicht berechnet
}

type Result struct{ Data string }

Der Vorteil gegenüber Sentinel-Werten (wie -1 für „kein Index" oder "" für „kein Name"): nil ist typsicher und vom Compiler abgesichert. Ein *TreeNode kann nicht versehentlich eine andere Bedeutung haben — entweder es ist ein gültiger Knoten oder es ist nil. Der Aufrufer kann nicht aus Versehen einen ungültigen Wert annehmen, weil jede Dereferenzierung den nil-Check erzwingt (oder paniciert).

Andere Sprachen formalisieren das per Option<T> (Rust) oder T? (C#/Kotlin). Go macht das mit *T — weniger explizit im Typsystem, aber tragfähig, solange die Konvention klar ist.

*T vs. T{} — wann Pointer, wann Zero-Struct

Eine ständige Design-Frage: Soll ein Feld vom Typ Config oder *Config sein? Beide haben einen Zero Value, aber mit unterschiedlicher Semantik:

AspektConfig (Wert)*Config (Pointer)
Zero ValueConfig{} — alle Felder Zeronil
"Gesetzt vs. nicht gesetzt"Nicht unterscheidbar — leerer Default sieht aus wie absichtlich leernil vs. nicht-nil ist klar
Größe in der Parent-Structsizeof(Config) — kann groß sein8 Byte (64-Bit)
Mutation durch MethodenPointer-Receiver erzwingt AdressierbarkeitMethoden mit Pointer-Receiver direkt nutzbar
Defensive Check nötigNein — Felder immer lesbarJa — sonst Panic-Risiko
JSON-EncodingFelder werden immer geschrieben (außer mit omitempty)nil-Felder fallen mit omitempty raus

Die Faustregel:

  • T als Wert, wenn der Zero-Struct ein sinnvoller Default ist und du keine „nicht gesetzt"-Semantik brauchst. Klassiker: sync.Mutex, bytes.Buffer, time.Time.
  • *T als Pointer, wenn das Feld optional ist, „kein Wert" semantisch von „leerer Wert" unterscheidbar sein muss, oder die Struktur teuer zu kopieren ist.
Go t-vs-pt.go
package main

import "fmt"

type Logger struct{ Level string }

// Wert-Variante: Logger ist immer vorhanden, hat aber ggf. Default-Level
type ServerA struct {
    Logger Logger
}

// Pointer-Variante: Logger ist optional — nil = nicht gesetzt
type ServerB struct {
    Logger *Logger
}

func main() {
    a := ServerA{}
    fmt.Printf("%+v\n", a) // {Logger:{Level:}}
    fmt.Println("Level leer?", a.Logger.Level == "")

    b := ServerB{}
    fmt.Printf("%+v\n", b) // {Logger:<nil>}
    fmt.Println("Logger fehlt?", b.Logger == nil)
}
Output
{Logger:{Level:}}
Level leer? true
{Logger:<nil>}
Logger fehlt? true

In APIs, in denen Aufrufer Felder selektiv setzen können sollen (Konfigurations-Structs, Options-Pattern), führt *T zu klarerer Semantik. Bei „immer vorhandene" Daten ist T einfacher und billiger im Speicher-Overhead.

Häufige Stolperfallen

Dereferenzieren ohne nil-Check paniciert.

var p *Config; p.Host ergibt runtime error: invalid memory address or nil pointer dereference. Auch p.Feld ist eine Dereferenzierung — Go macht (*p).Feld nur implizit lesbarer. Wer einen Pointer aus optionaler Quelle bezieht (Map-Lookup, Suchfunktion, Config-Feld), prüft vor dem Zugriff.

typed-nil im Interface ist NICHT nil — der Klassiker.

var p *T = nil; var i any = p; i == nil ist false. Ein Interface speichert Typ und Wert getrennt; sobald der Typ-Slot gesetzt ist, ist das Interface non-nil. Bei error-Returns niemals eine *MyError-Variable zurückgeben, die nil sein kann — immer explizit return nil.

Methode auf nil-Receiver: legal, solange Receiver-Felder nicht dereferenziert werden.

func (n *Node) Length() int darf auf einem nil-*Node aufgerufen werden — der Methoden-Dispatch löst keinen Panic aus. Erst der Zugriff auf n.Next oder n.Value kracht. Saubere Methoden prüfen if n == nil am Anfang und behandeln den Fall sinnvoll (oft als „leerer Zustand").

Map-Lookup gibt Zero Value zurück — bei Pointer-Values also nil.

m["fehlt"] paniciert nicht, sondern liefert nil. Die Folge-Dereferenzierung m["fehlt"].Feld paniciert dann doch. Bei Maps mit Pointer-Values immer mit der comma-ok-Form arbeiten: v, ok := m[key]; if !ok { ... }. Oder den nil-Check auf das Ergebnis legen.

errors.New gibt nicht-nil zurück — nil heißt „kein Fehler“.

Ein konstruierter Error ist nie nil. Der nil-error ist die Konvention für „alles gut". Wer return errors.New("") schreibt, gibt einen nicht-nil-Error zurück — auch wenn der Text leer ist. Für „kein Fehler" muss man return nil schreiben.

nil-Slice ist OK für append, nil-Map ist NICHT OK für Write.

var s []int; s = append(s, 1) funktioniert — append allokiert bei Bedarf. var m map[string]int; m["x"] = 1 paniciert mit assignment to entry in nil map. Maps muss man vor dem Schreiben mit make oder Literal initialisieren; Slices nicht.

Pointer auf Wert mit Zero-Inhalt vs. nil-Pointer — semantisch verschieden.

return &Config{} und return nil haben unterschiedliche Bedeutung. Ersteres ist „Config-Objekt existiert, alle Felder Zero". Letzteres ist „kein Config-Objekt". Aufrufer, die if cfg == nil prüfen, sehen die beiden Fälle anders. Konsistent bleiben: API klar dokumentieren, welcher der beiden Fälle was bedeutet.

Defensive Checks bei Funktionen, die garantiert nicht-nil zurückgeben, sind Code-Smell.

func NewServer() *Server wird in der Stdlib- und Idiom-Konvention immer nicht-nil zurückgeben. Wer trotzdem if s := NewServer(); s != nil { ... } schreibt, signalisiert Misstrauen ohne Anlass — Rauschen, das beim Lesen ablenkt. Vertrau dem Funktions-Vertrag; bei Bug-Verdacht lieber Test schreiben als jeden Call mit Check umrahmen.

GC räumt nil-Targets nicht auf — es gibt nichts zum Aufräumen.

Ein nil-Pointer hält keine Referenz. Wer einen Pointer auf nil setzt, um GC zu „helfen", gibt damit die vorher gehaltene Referenz frei — das ist der Punkt. Der nil-Zustand selbst kostet nichts und braucht keine Aufräum-Arbeit.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Pointer

Zur Übersicht