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
nilis 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:
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)
}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:
package main
type Config struct{ Host string }
func main() {
var c *Config // nil
_ = c.Host // panic
}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.Feldist eine Dereferenzierung. Der Selektor-Ausdruckp.Feldist äquivalent zu(*p).Feld— Go macht das implizite Dereferenzieren nur lesbarer. Wennp == 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 einemdefereinfangen, 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.
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)
}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() *Configwird 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.
nil-Receiver-Methoden — legal und nützlich
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:
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]->[]
}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
nilonly if theVandTare both unset. If we store anilpointer of type*intinside an interface value, the inner type will be*intregardless of the value of the pointer: (T=*int,V=nil). Such an interface value will therefore be non-nileven when the pointer valueVinside isnil.
Im Code wird das gefährlich, weil die Falle still ist — keine Compile-Warnung, kein Panic, nur logischer Schaden:
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
}bad err == nil: false
good err == nil: trueWarum: 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:
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)
}true true true true trueWichtige 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:
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:
| Aspekt | Config (Wert) | *Config (Pointer) |
|---|---|---|
| Zero Value | Config{} — alle Felder Zero | nil |
| "Gesetzt vs. nicht gesetzt" | Nicht unterscheidbar — leerer Default sieht aus wie absichtlich leer | nil vs. nicht-nil ist klar |
| Größe in der Parent-Struct | sizeof(Config) — kann groß sein | 8 Byte (64-Bit) |
| Mutation durch Methoden | Pointer-Receiver erzwingt Adressierbarkeit | Methoden mit Pointer-Receiver direkt nutzbar |
| Defensive Check nötig | Nein — Felder immer lesbar | Ja — sonst Panic-Risiko |
| JSON-Encoding | Felder werden immer geschrieben (außer mit omitempty) | nil-Felder fallen mit omitempty raus |
Die Faustregel:
Tals Wert, wenn der Zero-Struct ein sinnvoller Default ist und du keine „nicht gesetzt"-Semantik brauchst. Klassiker:sync.Mutex,bytes.Buffer,time.Time.*Tals Pointer, wenn das Feld optional ist, „kein Wert" semantisch von „leerer Wert" unterscheidbar sein muss, oder die Struktur teuer zu kopieren ist.
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)
}{Logger:{Level:}}
Level leer? true
{Logger:<nil>}
Logger fehlt? trueIn 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
- Pointer types – Go Language Specification
- Predeclared identifiers – Go Specification
- Why is my nil error value not equal to nil? – Go FAQ
- The zero value – Effective Go
nilness– go vet Analyzer für nil-Pfade
Verwandte Artikel
- Pointer – Übersicht und didaktische Einführung
- Pointer-Grundlagen – *T, &x und das Typsystem
- & und * – Adress-Operator und Dereferenzierung im Detail
- Pointer vs. Wert – wann welche Semantik
- Pointer als Parameter – Mutation, Performance, Idiomatik
- Zero Values – mit welchen Werten Variablen starten
- Interfaces – Übersicht und das (Typ, Wert)-Paar