Wer in C eine lokale Variable deklariert, ohne sie zu initialisieren, hält ein Stück undefiniertes Verhalten in der Hand — der Speicher enthält, was dort gerade lag, und der Compiler darf das frei interpretieren. Go macht es genau andersherum: jede deklarierte Variable wird garantiert auf ihren Zero Value gesetzt, einen für ihren Typ wohldefinierten Default. var x int ist 0, var s string ist "", var m map[string]int ist nil. Das ist nicht nur eine Bequemlichkeit — es ist ein zentrales Design-Prinzip der Sprache. Mit ihm verschwinden ganze Bug-Klassen, und Standard-Library-Typen wie sync.Mutex oder bytes.Buffer sind ohne Konstruktor benutzbar. Aber: Zero Value ist nicht gleich Zero Value. Eine nil-Slice darf man beschreiben, eine nil-Map nicht. Eine Interface mit nil-Wert ist nicht selbst nil. Dieser Artikel arbeitet beide Seiten sauber durch.

Was ein Zero Value ist

Die Go-Spec definiert den Mechanismus in zwei Sätzen. Unter „Variable declarations":

If a list of expressions is given, the variables are initialized with the expressions following the rules for assignment statements. Otherwise, each variable is initialized to its zero value.

Und unter „The zero value":

When storage is allocated for a variable, either through a declaration or a call of new, or when a new value is created, either through a composite literal or a call of make, and no explicit initialization is provided, the variable or value is given a default value. Each element of such a variable or value is set to the zero value for its type.

In einem Satz: Jedes Stück Speicher, das Go reserviert, wird auf Null-Bytes gesetzt — und diese Null-Bytes ergeben für jeden Typ einen sinnvoll definierten Wert. Es gibt drei Wege, wie das passiert:

  • Eine Variablen-Deklaration ohne Initialisierer (var x int).
  • Ein Aufruf von new(T) — liefert einen Pointer auf eine frisch genullte T-Variable.
  • Ein Composite Literal mit ausgelassenen Feldern (Point{X: 3} lässt Y auf Zero).

Auch make([]int, 100) liefert einen Slice, dessen 100 Elemente alle Zero-Values ihres Typs (int → 0) tragen. Garbage gibt es in Go nicht — die Sprache garantiert das auf der Speicher-Allokations-Ebene.

Zero Values aller Typ-Kategorien

Die Liste ist überschaubar. Go unterscheidet zwischen Value Types (Bool, Numeric, String, Array, Struct) — deren Zero Value besteht aus tatsächlichen Null-Bits — und Reference Types (Pointer, Slice, Map, Channel, Function, Interface), deren Zero Value das vordeklarierte nil ist:

KategorieTyp(en)Zero Value
Booleanboolfalse
Integerint, int8/16/32/64, uint, uint8/16/32/64, uintptr, byte, rune0
Floating Pointfloat32, float640.0
Complexcomplex64, complex1280+0i
Stringstring"" (leerer String, Länge 0)
Pointer*Tnil
Functionfunc(...) ...nil
Interfaceany, error, eigene Interfacesnil
Slice[]Tnil (Länge 0, Kapazität 0)
Mapmap[K]Vnil
Channelchan T, chan<- T, <-chan Tnil
Array[N]Tjedes Element auf Zero von T
Structstruct{...}jedes Feld auf Zero seines Typs

Zwei Beobachtungen, die später wichtig werden:

  • Array und Struct sind Value Types, die rekursiv aus den Zero-Values ihrer Elemente bestehen. Es gibt nie ein „Array mit nil-Inhalt" — die Speicherzellen sind real.
  • Slice, Map und Channel sehen aus wie Composite Types, sind aber Reference Types. Ihr Zero Value ist nil, weil der zugrundeliegende Header (Pointer auf Backing-Array bzw. Hash-Tabelle bzw. Runtime-Channel) auf null zeigt.

Code-Demo — jeden Zero Value einmal sehen

Am schnellsten verinnerlicht man die Tabelle, indem man sie ausführt. fmt.Printf mit dem Verb %#v liefert die Go-Syntax-Darstellung — damit sieht man auch nil und unterscheidet 0 von 0.0:

Go zero_demo.go
package main

import "fmt"

type Point struct {
    X, Y int
}

func main() {
    var (
        b   bool
        i   int
        f   float64
        c   complex128
        s   string
        p   *int
        fn  func()
        sl  []int
        m   map[string]int
        ch  chan int
        arr [3]int
        pt  Point
        any any
    )

    fmt.Printf("bool       %#v\n", b)
    fmt.Printf("int        %#v\n", i)
    fmt.Printf("float64    %#v\n", f)
    fmt.Printf("complex128 %#v\n", c)
    fmt.Printf("string     %#v\n", s)
    fmt.Printf("*int       %#v\n", p)
    fmt.Printf("func()     %#v\n", fn)
    fmt.Printf("[]int      %#v (len=%d cap=%d, nil=%v)\n",
        sl, len(sl), cap(sl), sl == nil)
    fmt.Printf("map        %#v (nil=%v)\n", m, m == nil)
    fmt.Printf("chan int   %#v\n", ch)
    fmt.Printf("[3]int     %#v\n", arr)
    fmt.Printf("Point      %#v\n", pt)
    fmt.Printf("any        %#v (nil=%v)\n", any, any == nil)
}
Output
bool       false
int        0
float64    0
complex128 (0+0i)
string     ""
*int       (*int)(nil)
func()     (func())(nil)
[]int      []int(nil) (len=0 cap=0, nil=true)
map        map[string]int(nil) (nil=true)
chan int   (chan int)(nil)
[3]int     [3]int{0, 0, 0}
Point      main.Point{X:0, Y:0}
any        <nil> (nil=true)

Bemerkenswert ist, was nicht auftaucht: kein „undefined", kein zufälliger Speicher-Müll, keine NaN bei Floats. Jeder Wert ist exakt das, was die Spec verspricht — und du kannst damit sofort weiterarbeiten.

Slice vs. Map — der wichtige Unterschied

Auf den ersten Blick wirken Slice, Map und Channel symmetrisch: alle drei sind Reference Types mit Zero Value nil. In der Praxis verhalten sie sich aber sehr unterschiedlich. Konkret: ein nil-Slice ist voll benutzbar, eine nil-Map paniced beim Schreiben. Das ist eine der ersten Stellen, an denen Go-Anfänger stolpern.

Go nil_slice_vs_map.go
package main

import "fmt"

func main() {
    var s []int            // nil-Slice
    var m map[string]int   // nil-Map

    // Slice: alles erlaubt
    fmt.Println("len(s):", len(s))   // 0
    fmt.Println("cap(s):", cap(s))   // 0
    s = append(s, 1, 2, 3)            // append funktioniert!
    fmt.Println("nach append:", s)    // [1 2 3]

    // Range über nil-Slice — null Iterationen, kein Fehler
    for _, v := range s {
        _ = v
    }

    // Map: Lesen geht, Schreiben paniced
    fmt.Println("len(m):", len(m))    // 0
    fmt.Println("m[\"x\"]:", m["x"])  // 0 — Zero Value des Value-Typs
    _, ok := m["x"]
    fmt.Println("exists:", ok)         // false

    // m["x"] = 1   // <-- PANIC: assignment to entry in nil map
    m = make(map[string]int)          // erst initialisieren
    m["x"] = 1
    fmt.Println("nach make:", m)      // map[x:1]
}
Output
len(s): 0
cap(s): 0
nach append: [1 2 3]
len(m): 0
m["x"]: 0
exists: false
nach make: map[x:1]

Warum dieser Unterschied? append ist eine Built-in-Funktion, die einen neuen Slice-Header zurückliefert — sie darf bei nil einfach einen frischen Backing-Array allokieren. Map-Zuweisungen dagegen sind keine Funktion, sondern eine direkte Hash-Table-Operation, und die Runtime kann kein Bucket-Array aus dem Nichts erfinden, ohne dass der Header der Map vorher per make (oder Literal) initialisiert wurde.

Praktische Konsequenzen:

  • Slices kannst du immer „leer" deklarieren und mit append auffüllen — der Code funktioniert egal ob nil-Slice oder leerer ([]int{}) Slice. JSON-Serialisierung ist der einzige Unterschied: nil wird zu null, []int{} zu [].
  • Maps brauchen immer ein make oder Literal vor dem ersten Schreiben. Wer eine Map als Struct-Feld hat, initialisiert sie im Konstruktor oder vor dem ersten Schreibzugriff.
  • Channels ähneln Maps: aus einem nil-Channel zu lesen oder zu senden blockiert für immer (was gelegentlich als bewusster Sync-Trick genutzt wird), Closen eines nil-Channels paniced.

Struct-Zero-Value — rekursiv durch alle Felder

Bei Structs greift der Zero-Value-Mechanismus rekursiv: jedes Feld bekommt den Zero Value seines Typs, jedes geschachtelte Feld ebenfalls. Es gibt keinen Konstruktor-Lauf, kein implizites „new" für Pointer-Felder — was Pointer, Slice oder Map ist, bleibt nil:

Go struct_zero.go
package main

import "fmt"

type Address struct {
    Street string
    Number int
}

type User struct {
    ID      int
    Name    string
    Active  bool
    Tags    []string          // Slice — nil
    Meta    map[string]string // Map  — nil
    Addr    Address           // Struct — rekursiv genullt
    Manager *User             // Pointer — nil
}

func main() {
    var u User
    fmt.Printf("%#v\n", u)

    // Felder einzeln antippen
    fmt.Println("ID:        ", u.ID)
    fmt.Println("Name:      ", u.Name == "")
    fmt.Println("Tags nil:  ", u.Tags == nil)
    fmt.Println("Meta nil:  ", u.Meta == nil)
    fmt.Println("Addr:      ", u.Addr) // Address{"", 0}
    fmt.Println("Manager:   ", u.Manager == nil)

    // Tags ist nil — append funktioniert trotzdem
    u.Tags = append(u.Tags, "admin")

    // Meta ist nil — Schreiben würde panicen
    // u.Meta["role"] = "x"   // PANIC
    u.Meta = map[string]string{"role": "x"} // erst initialisieren

    // Composite Literal mit ausgelassenen Feldern — selbe Mechanik
    u2 := User{Name: "Ada"}
    fmt.Printf("%#v\n", u2)
}
Output
main.User{ID:0, Name:"", Active:false, Tags:[]string(nil), Meta:map[string]string(nil), Addr:main.Address{Street:"", Number:0}, Manager:(*main.User)(nil)}
ID:         0
Name:       true
Tags nil:   true
Meta nil:   true
Addr:       { 0}
Manager:    true
main.User{ID:0, Name:"Ada", Active:false, Tags:[]string(nil), Meta:map[string]string(nil), Addr:main.Address{Street:"", Number:0}, Manager:(*main.User)(nil)}

Das gleiche Prinzip gilt für Composite Literals: in User{Name: "Ada"} bekommen alle nicht genannten Felder ihren Zero Value. Du musst nicht alle Felder aufzählen — User{} ist ein vollständig konstruiertes, valides Objekt.

„Useful Zero Value" — das Design-Idiom

Die wahre Stärke von Zero Values steckt nicht in der Sprache selbst, sondern in einem Design-Prinzip, das Effective Go formuliert:

Since the memory returned by new is zeroed, it's helpful to arrange when designing your data structures that the zero value of each type can be used without further initialization.

Frei übersetzt: Entwirf deine Typen so, dass ihr Zero Value bereits ein sinnvoll benutzbarer Zustand ist. Die Standard-Library lebt das vor:

Go useful_zero.go
package main

import (
    "bytes"
    "fmt"
    "sync"
)

func main() {
    // bytes.Buffer — Zero Value ist ein leerer, sofort benutzbarer Buffer
    var buf bytes.Buffer
    buf.WriteString("Hallo ")
    buf.WriteString("Welt")
    fmt.Println(buf.String()) // Hallo Welt

    // sync.Mutex — Zero Value ist ein un-gelockter Mutex
    var mu sync.Mutex
    mu.Lock()
    // ... kritischer Abschnitt ...
    mu.Unlock()

    // sync.WaitGroup, sync.Once, sync.RWMutex — alle dasselbe Muster
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("done")
    }()
    wg.Wait()
}
Output
Hallo Welt
done

Kein NewBuffer(), kein NewMutex() — das ist Absicht. Wer eigene Typen entwirft, sollte sich dieselbe Frage stellen: Kann mein Typ mit var x T direkt benutzbar sein? Wenn ja, spare dir den Konstruktor. Wenn nein, biete einen NewT()-Konstruktor an und dokumentiere, dass der Zero Value nicht ausreicht.

Die Eigenschaft propagiert sich auch transitiv: ein Struct aus lauter Typen mit useful Zero Value hat selbst einen useful Zero Value:

Go syncedbuffer.go
package main

import (
    "bytes"
    "sync"
)

// Zero Value ist sofort benutzbar — beide Felder haben useful Zero Values
type SyncedBuffer struct {
    lock   sync.Mutex
    buffer bytes.Buffer
}

func (s *SyncedBuffer) Write(p []byte) (int, error) {
    s.lock.Lock()
    defer s.lock.Unlock()
    return s.buffer.Write(p)
}

func main() {
    var sb SyncedBuffer        // direkt benutzbar
    sb.Write([]byte("hi"))

    p := new(SyncedBuffer)     // auch das funktioniert
    p.Write([]byte("ho"))
    _ = p
    _ = sb
}

Wann der Zero Value nicht reicht

So elegant das Prinzip ist — es trägt nicht in jeder Situation. Drei klassische Fälle, in denen du bewusst initialisieren musst, statt auf den Zero Value zu vertrauen:

Maps. Wie gerade gezeigt: nil-Map ist beim Schreiben tödlich. Wer eine Map als Struct-Feld hat, initialisiert sie in einem Konstruktor oder beim ersten Setzen:

Go map_init.go
type Cache struct {
    entries map[string]string
}

// Variante A: Konstruktor
func NewCache() *Cache {
    return &Cache{entries: make(map[string]string)}
}

// Variante B: lazy init im ersten Setter
func (c *Cache) Set(k, v string) {
    if c.entries == nil {
        c.entries = make(map[string]string)
    }
    c.entries[k] = v
}

Pointer-Felder, die auf etwas zeigen sollen. Ein *User als Feld ist nil im Zero Value. Dereferenzieren paniced — wer das Feld benutzen will, muss es vorher zuweisen. Wenn das Feld optional ist, ist nil die natürliche Darstellung dafür.

Drei-Wert-Logik / Optional-Werte. Manchmal musst du zwischen „Wert ist 0" und „Wert wurde nie gesetzt" unterscheiden. Der Zero Value kann das nicht — var x int ist 0, egal ob bewusst gesetzt oder nicht. Die gängigen Lösungen:

Go optional.go
package main

import (
    "database/sql"
    "fmt"
)

type Profile struct {
    // *string: nil = nicht gesetzt, sonst gesetzter Wert
    Phone *string

    // sql.NullString: explizites Valid-Flag
    Bio sql.NullString

    // generischer Pattern in Go 1.18+: eigener Optional-Typ
    // Score Optional[int]
}

func main() {
    p := Profile{}
    fmt.Println("Phone gesetzt:", p.Phone != nil)         // false
    fmt.Println("Bio gesetzt:  ", p.Bio.Valid)             // false

    phone := "+49 123"
    p.Phone = &phone
    p.Bio = sql.NullString{String: "Hi", Valid: true}
    fmt.Println("Phone:", *p.Phone, "Bio:", p.Bio.String)
}

Drei Tools für dasselbe Problem: Pointer auf den Wert-Typ, explizite Null-Wrapper-Strukturen aus database/sql, oder ein eigener Optional[T]-Typ mit Generics. Welcher passt, hängt vom Kontext ab — bei JSON-APIs und DB-Mappings sind Pointer am häufigsten, weil sich nil direkt auf null abbildet.

Time und andere Strukturen mit „komischem" Zero Value

Ein Sonderfall, der regelmäßig irritiert: time.Time{}. Der Zero Value einer time.Time ist nicht der Unix-Epoch-Zeitpunkt (1. Januar 1970) und auch nicht „now" — sondern der 1. Januar des Jahres 1, 00:00 UTC. Das ist die Spec der Standard-Library und ein häufiger Stolperer bei Date-Vergleichen:

Go time_zero.go
package main

import (
    "fmt"
    "time"
)

func main() {
    var t time.Time
    fmt.Println("Zero time.Time:", t)        // 0001-01-01 00:00:00 +0000 UTC
    fmt.Println("IsZero?       ", t.IsZero())// true

    // Häufige Falle: prüfen ob ein Zeit-Feld gesetzt wurde
    // Falsch:  if t != time.Time{} — Struct-Vergleich, funktioniert,
    //         aber liest sich schlecht
    // Richtig: if !t.IsZero()
    if t.IsZero() {
        fmt.Println("Zeitpunkt noch nicht gesetzt")
    }
}
Output
Zero time.Time: 0001-01-01 00:00:00 +0000 UTC
IsZero?        true
Zeitpunkt noch nicht gesetzt

Das ist ein gutes Beispiel für die transitive Anwendung des Idioms: time.Time hat einen useful Zero Value plus die IsZero()-Methode, damit das „ungesetzt"-Sentinel sauber abgefragt werden kann.

Häufige Stolperfallen

In eine nil-Map schreiben paniced — immer make oder Literal vor dem ersten Set.

var m map[string]int; m[&quot;x&quot;] = 1 ergibt zur Laufzeit panic: assignment to entry in nil map. Lesen aus einer nil-Map ist hingegen erlaubt und liefert den Zero Value des Value-Typs. Wer eine Map als Struct-Feld hat, initialisiert sie im Konstruktor oder mit Lazy-Init im ersten Setter — oder verwendet von vornherein ein Composite Literal: m := map[string]int{}.

Interface mit nil-konkretem-Wert ist NICHT nil.

Ein Interface trägt intern ein Tupel (typ, wert). Das Interface ist nur dann == nil, wenn beide Komponenten nil sind. Wer einen typed-nil-Pointer in ein Interface verpackt (var p *int; var i any = p), erhält ein Interface, das i == nil mit false beantwortet — obwohl der enthaltene Wert nil ist. Klassiker bei Error-Returns: func f() error { var e *MyErr; return e } gibt ein „non-nil" Error-Interface zurück, das aber bei Aufruf einer Methode panict.

Zero-Struct ist mit == vergleichbar — solange alle Felder vergleichbar sind.

Point{} == Point{} ist true, weil Structs feld-weise verglichen werden. Aber sobald ein Feld vom Typ Slice, Map oder Function ist, ist der gesamte Struct nicht mehr mit == vergleichbar — der Compiler bricht ab. Für solche Typen gibt es reflect.DeepEqual oder im Test-Code das cmp-Paket.

time.Time{} ist nicht Unix-Epoch — es ist 0001-01-01.

Der Zero Value von time.Time ist Jahr 1, nicht 1970. Wer einen Zeitstempel als „ungesetzt" markieren will, prüft mit t.IsZero() — nicht mit t.Unix() == 0 (das wäre 1970-01-01) und auch nicht durch Struct-Vergleich t == time.Time{} (funktioniert, ist aber unidiomatisch).

close auf einem nil-Channel paniced — Send/Receive blockieren ewig.

var ch chan int; close(ch) ergibt panic: close of nil channel. ch <- 1 und <-ch auf nil-Channels blockieren dagegen unwiderruflich — was in select-Statements gelegentlich als bewusster Trick verwendet wird, um einen Case temporär „auszuschalten". Wer das nicht beabsichtigt, hat einen Deadlock gebaut.

nil-Slice vs. leerer Slice — bei JSON ein Unterschied.

var s []int ist nil, s := []int{} ist nicht nil aber leer. Für len, cap, range, append verhalten sich beide identisch. Aber: encoding/json serialisiert nil-Slices als null, leere Slices als []. Wer ein API-Schema mit „immer ein Array, niemals null" hat, initialisiert explizit mit []T{}.

new(T) vs. make(T) verwechseln.

new(T) liefert einen *T, der auf eine genullte T-Instanz zeigt. make(T, ...) liefert einen initialisierten T-Wert — gibt es nur für Slice, Map und Channel. new([]int) ergibt einen Pointer auf einen nil-Slice — fast nie das Gewünschte. Richtig ist make([]int, 100) oder ein Literal []int{}. new braucht man in der Praxis selten; &T{} ist meistens lesbarer.

Pointer-Felder im Zero-Value-Struct sind nil — Dereferenzieren paniced.

Wer einen Struct mit Pointer-Feldern per var u User anlegt und auf *u.Manager zugreift, kassiert einen nil-pointer-dereference. Entweder im Konstruktor zuweisen, oder vor dem Zugriff per if u.Manager != nil absichern. Bei verschachtelten Strukturen ist das die häufigste Ursache für invalid memory address-Panics.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Variablen & Konstanten

Zur Übersicht