Go kennt zwei Wege, eine Variable einzuführen: die ausführliche var-Form und die kurze :=-Form, im Volksmund Walrus-Operator genannt. Beide sind nicht beliebig austauschbar — sie haben unterschiedliche Erlaubnisbereiche, eine eigene Redeclaration-Mechanik und je eigene Schwächen. Wer sie sauber auseinanderhält, schreibt idiomatischen Go-Code und vermeidet die häufigste Anfänger-Falle: das versehentliche Shadowing durch ein zu eifriges :=. Dieser Artikel arbeitet beide Formen formal durch — Syntax, Geltungsbereich, Initialisierung — und zeigt am Ende, welche Faustregeln in der echten Codebase tragen.
Die zwei Deklarationsformen im Vergleich
Bevor wir in die Details gehen, der schnelle Überblick. Beide Formen führen eine Variable ein, weisen ihr einen Typ zu und können einen Initialwert setzen — sie unterscheiden sich aber, wo sie stehen dürfen und welche Sonderfälle sie zulassen:
| Eigenschaft | var | := (Short Variable Declaration) |
|---|---|---|
| Erlaubt auf Paket-Ebene | Ja | Nein — nur innerhalb von Funktionen |
| Typ explizit angebbar | Ja (var x int) | Nein — immer aus Initialisierer abgeleitet |
| Initialisierer optional | Ja (Zero Value greift) | Nein — Initialisierer ist Pflicht |
| Nil ohne Typ | Verboten (var n = nil) | Verboten (n := nil) |
| Redeclaration im selben Block | Nein | Ja, wenn mindestens ein neuer Name dabei ist |
| Sichtbar ab | Ende des VarSpec | Ende des ShortVarDecl |
Die einzige formale Definition stammt aus der Go-Spec:
A short variable declaration uses the syntax
IdentifierList ":=" ExpressionList. It is shorthand for a regular variable declaration with initializer expressions but no types.
Mit anderen Worten: x := 42 ist semantisch identisch zu var x = 42. Es ist nur kürzer — und an mehr Stellen einsetzbar (mehr dazu im Abschnitt zu if/for/switch).
var — die ausführliche Form in allen Varianten
var ist die universelle Deklarationsform: erlaubt auf Paket- und auf Funktionsebene, mit oder ohne Typ, mit oder ohne Initialisierer. Die formale Syntax aus der Spec:
VarDecl = "var" ( VarSpec | "(" { VarSpec ";" } ")" ) .
VarSpec = IdentifierList ( Type [ "=" ExpressionList ] | "=" ExpressionList ) .Daraus ergeben sich vier praktische Varianten:
package main
// (1) Mit Typ, ohne Initialisierer — Variable bekommt Zero Value
var port int // port == 0
var name string // name == ""
var active bool // active == false
var data []byte // data == nil
// (2) Mit Typ und Initialisierer
var maxRetries int = 5
var greeting string = "Hallo"
// (3) Ohne Typ, mit Initialisierer (Typ wird abgeleitet)
var timeout = 30 // timeout ist int (Default-Typ untyped 30)
var ratio = 1.5 // ratio ist float64
var tag = "release" // tag ist string
// (4) Block-Form für mehrere Deklarationen
var (
host string = "localhost"
port int = 8080
tls = false // bool, abgeleitet
allowedIPs []string // nil-Slice
)Die Block-Form ist auf Paket-Ebene idiomatisch — sie gruppiert verwandte Konfigurations- oder Zustandsvariablen optisch zu einer Einheit und spart die var-Wiederholung. Innerhalb von Funktionen sieht man sie selten; dort regiert :=.
Mehrfach-Deklaration in einer Zeile. Mehrere Variablen gleichen Typs kann man in einer Zeile zusammenfassen — typsicher und kompakt:
var a, b, c int = 1, 2, 3
var x, y = "left", "right" // beide string
var u, v float64 // beide 0.0Der Nil-Sonderfall. Ohne expliziten Typ akzeptiert Go kein nil als Initialwert — der Compiler wüsste nicht, welches nil gemeint ist (nil-Slice, nil-Map, nil-Pointer, nil-Interface — alles unterschiedlich):
var n = nil // FEHLER: use of untyped nil in variable declaration
var p *int = nil // OK — Typ ist explizit
var m map[string]int // OK — Zero Value einer Map ist nil
var s []byte // OK — Zero Value eines Slice ist nilMerksatz: Wer nil braucht und keinen anderen Initialwert hat, lässt die Zuweisung weg — der Zero-Value-Mechanismus gibt die richtige Nil-Variante automatisch.
:= — der Walrus, knapp und funktions-lokal
Der Walrus-Operator := deklariert eine oder mehrere Variablen und weist ihnen einen Wert zu, alles in einem Statement. Sein Geltungsbereich ist auf Funktionen beschränkt — und genau dort entfaltet er seine Stärke, weil er in if-, for- und switch-Initialisierungen passt, wo var nicht erlaubt ist:
package main
import (
"fmt"
"strings"
)
func main() {
// Einfache Deklaration
greeting := "Hallo Welt"
count := 42
ratio := 3.14
// Multi-Return aus Funktion auspacken
parts := strings.SplitN(greeting, " ", 2)
first, second := parts[0], parts[1]
// In if-Initialisierung — klassischer Pattern für Error-Handling
if idx := strings.Index(greeting, "Welt"); idx >= 0 {
fmt.Println("gefunden bei", idx)
}
// idx ist hier außerhalb des if nicht mehr sichtbar.
// In for-Initialisierung
for i := 0; i < 3; i++ {
fmt.Println(i, first, second, count, ratio)
}
}gefunden bei 6
0 Hallo Welt 42 3.14
1 Hallo Welt 42 3.14
2 Hallo Welt 42 3.14Drei Regeln, die := von var abgrenzen:
- Nur innerhalb von Funktionen.
x := 1auf Paket-Ebene ist ein Syntax-Fehler. Wer Top-Level-Variablen braucht, nimmtvar. - Initialisierer ist Pflicht.
x :=ohne Wert ist nicht zulässig — der Typ wird ja aus dem Wert abgeleitet, ohne Wert kein Typ. - Mindestens ein neuer Name links. Sobald keiner der Namen neu ist, beschwert sich der Compiler mit
no new variables on left side of :=.
Die Redeclaration-Regel — wo Anfänger stolpern
Die wichtigste und subtilste Eigenheit des Walrus-Operators steht in der Spec:
A short variable declaration may redeclare variables provided they were originally declared earlier in the same block (or the parameter lists if the block is the function body) with the same type, and at least one of the non-blank variables is new. As a consequence, redeclaration can only appear in a multi-variable short declaration. Redeclaration does not introduce a new variable; it just assigns a new value to the original.
Praktisch heißt das: Wenn auf der linken Seite mindestens ein neuer Identifier steht, dürfen die anderen Namen bereits existieren — sie werden dann zugewiesen, nicht neu angelegt. Genau diese Mechanik macht den klassischen Error-Handling-Pattern möglich:
package main
import (
"fmt"
"strconv"
)
func main() {
// erster Aufruf: n neu, err neu
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("Parse-Fehler:", err)
return
}
fmt.Println("erste Zahl:", n)
// zweiter Aufruf: m neu, err WIEDERVERWENDET (gleiche err-Variable!)
m, err := strconv.Atoi("100")
if err != nil {
fmt.Println("Parse-Fehler:", err)
return
}
fmt.Println("zweite Zahl:", m)
// dritter Versuch: Würde fehlschlagen — kein neuer Name dabei
// n, err := strconv.Atoi("7") // FEHLER: no new variables on left side of :=
n, err = strconv.Atoi("7") // korrekt mit = (reine Zuweisung)
_ = err
fmt.Println("dritte Zahl:", n)
}erste Zahl: 42
zweite Zahl: 100
dritte Zahl: 7Drei Feinheiten, die hier mitspielen:
- „Im selben Block". Die Redeclaration funktioniert nur, wenn die ursprüngliche Variable im gleichen Block deklariert wurde. Ein
if-Body öffnet einen neuen Block — dort führt:=selbst dann zu einer neuen Variable, wenn der Name außen schon existiert. Das ist die Shadowing-Falle aus dem Scoping-Artikel. - „Gleicher Typ". Wer
errursprünglich alserrordeklariert hat, kann es nicht durch Redeclaration plötzlich zustringmachen. Der Compiler verlangt Typ-Kompatibilität. - „Mindestens ein neuer Name." Reine Zuweisung an bereits existierende Variablen wird mit
=, nicht mit:=geschrieben. Das ist eine bewusste Abgrenzung — Go will sehen, ob der Programmierer eine neue Variable im Sinn hat oder einer bestehenden einen neuen Wert geben will.
Die Shadowing-Falle in if- und for-Initialisierungen
Die häufigste Bug-Quelle in Go-Code unter Newcomers: ein := in einem inneren Block legt versehentlich eine neue Variable an, obwohl der Schreiber die äußere meinte. Klassiker — Error-Handling über mehrere Stufen:
package main
import (
"fmt"
"strconv"
)
func parseAndDouble(s string) (int, error) {
var n int
var err error
if s != "" {
// Falle: := im if-Body legt NEUE n und err an
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
n = n * 2
_ = n
// Die äußeren n und err sind hier UNBERÜHRT
}
// n ist immer noch 0, err immer noch nil
return n, err
}
func main() {
n, err := parseAndDouble("21")
fmt.Println(n, err) // erwartet wäre 42, tatsächlich: 0 <nil>
}0 <nil>Der Bug entsteht, weil der if-Body ein eigener Block ist — die Redeclaration-Regel greift nicht, beide Namen n und err sind aus Sicht des inneren Blocks neu. Korrekt wäre einer der beiden Wege:
// Variante A: Zuweisung statt Deklaration (mit =)
func parseAndDoubleA(s string) (int, error) {
var n int
var err error
if s != "" {
n, err = strconv.Atoi(s) // = statt :=
if err != nil {
return 0, err
}
n = n * 2
}
return n, err
}
// Variante B: Initialisierung direkt im if-Statement
func parseAndDoubleB(s string) (int, error) {
if s == "" {
return 0, nil
}
n, err := strconv.Atoi(s) // Funktions-Block-Scope
if err != nil {
return 0, err
}
return n * 2, nil
}Variante B ist die idiomatischere Form: früh-aussteigen, dann linear weiterarbeiten — Gos sogenannter „guard clause"-Stil. Wer den shadow-Analyzer von go vet (oder golangci-lint) aktiviert, bekommt diese Bugs frühzeitig gemeldet.
Was wann nehmen — die Faustregeln
Effective Go und der Style-Guide der Standard-Library geben einen klaren Korridor vor:
| Situation | Form |
|---|---|
| Variable auf Paket-Ebene | var (zwingend) |
| Variable nur deklarieren, ohne Initialwert (Zero Value reicht) | var (Pflicht — := braucht Initialisierer) |
| Variable mit Initialwert in einer Funktion | := |
Variable mit expliziter Typ-Angabe, die der Initialwert nicht hergibt (z. B. int64, float32, Interface) | var name Typ = wert |
Initialisierung im if/for/switch-Statement | := |
| Wert an existierende Variable zuweisen | = (kein :=) |
| Multi-Return mit teilweise vorhandenen Variablen, eine neue dabei | := (Redeclaration) |
| Mehrere zusammengehörende Top-Level-Variablen | var ( ... )-Block |
Das Effective-Go-Zitat dazu: „Inside a function, the := short declaration form can be used in place of a var declaration with implicit type. Such variables, however, must be initialized." Und einen Absatz später, fast als Warnung: „Be aware, though, that := is a declaration, whereas = is an assignment."
Ein subtiler Sonderfall — Typ-konkretisierung. Wenn der Initialisierer nicht den gewünschten Typ ergibt (etwa weil du int32 willst, der Literal aber int wäre), brauchst du var oder einen expliziten Cast:
// Will int32: untyped 0 würde sonst zu int werden
var counter int32
// Oder mit Cast
counter2 := int32(0)
// Will io.Reader (Interface), nicht *os.File (konkreter Typ)
var r io.Reader = os.Stdin
// r := os.Stdin // r wäre *os.File, kein io.ReaderIn solchen Fällen kostet var zwei Zeichen mehr, drückt aber genau aus, was du willst. Code-Reviewer mögen das.
Multi-Return und der Blank Identifier
Der Walrus-Operator glänzt bei Funktionen mit mehreren Return-Werten — der Klassiker ist die comma-ok- oder comma-err-Auswertung. Was du dabei nicht brauchst, blankst du mit _ aus:
package main
import (
"fmt"
"strconv"
)
func main() {
users := map[string]int{"alice": 30, "bob": 25}
// comma-ok auf Maps
age, ok := users["alice"]
if ok {
fmt.Println("alice:", age)
}
// Nur prüfen, ob Key existiert — Wert verwerfen
if _, exists := users["carol"]; !exists {
fmt.Println("carol fehlt")
}
// Nur den Wert holen, Existenz egal
v, _ := users["bob"]
fmt.Println("bob:", v)
// Typed nil: error verwerfen ist meistens ein Fehler-Smell
n, _ := strconv.Atoi("42")
fmt.Println("n =", n)
}alice: 30
carol fehlt
bob: 25
n = 42Drei Beobachtungen, die immer wiederkehren:
_ist kein gewöhnlicher Identifier — du kannst ihn nicht zurücklesen, und er konfliktet auch nicht mit anderen_-Verwendungen in der gleichen Zeile.- Wer Error-Werte mit
_verwirft, sollte einen guten Grund haben.strconv.Atoi("42")ist hier safe, weil der Input ein Literal ist — bei dynamischen Inputs ist_für Errors fast immer ein Bug. - Bei der
if-Initialisierung ist die comma-ok-Form besonders kompakt, weil die Variable sowieso nur im if-Body gebraucht wird.
Lebensdauer und Wirkungsbereich
Beide Formen erzeugen lokale oder Paket-lokale Variablen — sie unterscheiden sich nur im Wo, nicht im Wie der Speicher-Verwaltung. Der Go-Compiler entscheidet per Escape-Analyse, ob die Variable auf dem Stack lebt (schnell, automatisch entsorgt am Funktions-Ende) oder im Heap (wenn ein Pointer auf sie das Funktions-Ende überlebt). Das ist unabhängig von var vs. :=:
package main
// Variante A: Stack-lokal, escape-frei
func returnValue() int {
x := 42 // bleibt auf dem Stack
return x
}
// Variante B: Variable escaped via Pointer-Return → landet auf dem Heap
func returnPointer() *int {
y := 42 // selbe Syntax — Compiler erkennt das escape
return &y // y muss den Funktions-Frame überleben
}
// Test mit `go build -gcflags="-m"` zeigt die Escape-EntscheidungenHeißt: Wer eine Variable mit := deklariert, gibt damit nicht etwa „Stack-Allokation, bitte" an. Die Entscheidung trifft der Compiler — du steuerst sie indirekt darüber, ob die Variable per Pointer den Frame verlässt. Hilfreich beim Debuggen: go build -gcflags="-m" macht die Escape-Analyse sichtbar.
Häufige Stolperfallen
Shadowing durch := in inneren Blöcken ist Bug Nummer eins.
Der innere Block sieht keine Variable mit dem gleichen Namen — also legt := eine neue an. Die äußere bleibt unverändert. Aktiviere den shadow-Check in go vet oder golangci-lint, dann fängt CI das automatisch.
no new variables on left side of := — mindestens einer muss neu sein.
Wenn alle Namen links bereits im gleichen Block existieren, ist := kein Decl mehr — der Compiler bricht ab. Lösung: entweder = verwenden (reine Zuweisung), oder einen _ bzw. einen echten neuen Namen einfügen.
var x = nil ist nicht erlaubt — nil braucht einen Typ.
Ohne expliziten Typ weiß der Compiler nicht, welches nil gemeint ist (Slice, Map, Pointer, Interface, Channel). Entweder Typ angeben (var x *int) oder den Initialisierer weglassen (var s []int ergibt nil-Slice via Zero Value).
Untyped Constants werden zum Default-Typ konvertiert.
x := 5 macht x zu int, nicht zu int32 — auch wenn du intern int32 brauchst. Wer einen abweichenden Typ will, schreibt var x int32 = 5 oder x := int32(5). 1.5 wird float64, 'a' wird int32 (rune), nicht byte.
Auf Paket-Ebene ist := ein Syntax-Fehler.
Top-Level-Variablen müssen mit var deklariert sein. Wer Konstanten will, nimmt const — beide funktionieren auf Paket-Ebene. := ist ausschließlich für Funktions-Innenleben.
Multi-Return ohne ungenutzten Wert: := mit _ ausblenden.
Wenn eine Funktion zwei Werte zurückgibt, du aber nur einen brauchst, blendest du den anderen mit _ aus. Niemals einen Error-Wert kommentarlos in _ schicken — das ist fast immer ein versteckter Bug.
Block-var-Form auf Paket-Ebene gruppiert verwandte Werte.
Statt fünfmal var zu wiederholen, fasst ein var ( ... )-Block thematisch zusammen. gofmt richtet die Spalten automatisch aus — das Ergebnis liest sich wie eine kleine Konfigurations-Tabelle. Innerhalb von Funktionen ist die Form selten sinnvoll.
Variable deklariert, nie benutzt — Compiler-Fehler, kein Warning.
Go ist hier kompromisslos: Eine deklarierte, aber nie gelesene lokale Variable bricht den Build mit declared and not used. Workaround in Debug-Sessions: einmal _ = x schreiben, oder die Deklaration entfernen. Importe verhalten sich übrigens analog: ungenutzte Importe sind ebenfalls Build-Fehler.
Weiterführende Ressourcen
Externe Quellen
- Variable declarations – Go Language Specification
- Short variable declarations – Go Specification
- Declarations and scope – Go Specification
- Effective Go: Declarations
go vetshadow analyzer