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 ofmake, 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 genullteT-Variable. - Ein Composite Literal mit ausgelassenen Feldern (
Point{X: 3}lässtYauf 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:
| Kategorie | Typ(en) | Zero Value |
|---|---|---|
| Boolean | bool | false |
| Integer | int, int8/16/32/64, uint, uint8/16/32/64, uintptr, byte, rune | 0 |
| Floating Point | float32, float64 | 0.0 |
| Complex | complex64, complex128 | 0+0i |
| String | string | "" (leerer String, Länge 0) |
| Pointer | *T | nil |
| Function | func(...) ... | nil |
| Interface | any, error, eigene Interfaces | nil |
| Slice | []T | nil (Länge 0, Kapazität 0) |
| Map | map[K]V | nil |
| Channel | chan T, chan<- T, <-chan T | nil |
| Array | [N]T | jedes Element auf Zero von T |
| Struct | struct{...} | 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:
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)
}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.
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]
}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
appendauffüllen — der Code funktioniert egal obnil-Slice oder leerer ([]int{}) Slice. JSON-Serialisierung ist der einzige Unterschied:nilwird zunull,[]int{}zu[]. - Maps brauchen immer ein
makeoder 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 einesnil-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:
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)
}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
newis 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:
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()
}Hallo Welt
doneKein 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:
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:
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:
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:
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")
}
}Zero time.Time: 0001-01-01 00:00:00 +0000 UTC
IsZero? true
Zeitpunkt noch nicht gesetztDas 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["x"] = 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
- The zero value – Go Language Specification
- Variable declarations – Go Specification
- Composite literals – Go Specification
- Effective Go: Allocation with new
- Effective Go: Allocation with make
- Dave Cheney – The empty struct (zero size types)