Andere Sprachen haben drei oder vier Schleifen-Konstrukte — Go hat genau eines: for. Diese Reduktion ist Programm. Das eine Schlüsselwort deckt durch drei Grundformen alles ab, was eine klassische while-, do-while- oder for(;;)-Schleife in C oder Java leistet, und ergänzt das mit einer eigenen range-Variante, die über Slices, Maps, Strings, Channels und seit Go 1.22 sogar Integer iteriert. Dieser Artikel arbeitet die drei Grundformen sauber heraus, zeigt jede range-Variante mit lauffähigem Beispiel, erklärt die spürbare Verhaltensänderung der Loop-Variable seit Go 1.22 und sammelt am Ende die Stolperfallen, die in Reviews regelmäßig auffallen — von der Kopie-Semantik des Range-Werts über die randomisierte Map-Iteration bis zur Composite-Literal-Falle in der Condition.

Die formale Grammatik

Die Go-Spec definiert das for-Statement minimalistisch — eine einzige Produktion deckt alle drei Formen plus range ab:

EBNF ForStmt (Go-Spec)
ForStmt     = "for" [ Condition | ForClause | RangeClause ] Block .
ForClause   = [ InitStmt ] ";" [ Condition ] ";" [ PostStmt ] .
InitStmt    = SimpleStmt .
Condition   = Expression .
PostStmt    = SimpleStmt .
RangeClause = [ ExpressionList "=" | IdentifierList ":=" ] "range" Expression .

Daraus ergeben sich vier praktische Schreibweisen:

FormSchreibweiseTypische Verwendung
Klassischfor init; cond; post { ... }Zählschleife mit Index, C-Stil
While-artigfor cond { ... }Bedingungsschleife, Polling, Suche
Endlosfor { ... }Event-Loop, Server, Reader-Loop
rangefor k, v := range x { ... }Iteration über Container/Channels

Vier Eigenheiten, die Go von C/Java/JavaScript unterscheiden — und die für alle vier Formen gelten:

  • Keine Klammern um die Klausel. for (i := 0; i < n; i++) { ... } ist Syntax-Fehler. Die Schlüsselwörter for, range, break, continue brauchen keine Parens.
  • Geschweifte Klammern sind Pflicht. Auch bei einer Zeile im Body. for x { y() } ist OK, for x y() ist ein Syntax-Fehler.
  • Die drei Klauseln werden durch Semikolons getrennt — nur dann, wenn sie da sind. for cond { ... } braucht keine Semikolons.
  • Block-Zwang macht „goto fail"-Bugs unmöglich. Ein versteckter Single-Statement-Body wie in C gibt es nicht.

Form 1 — Klassisch mit Init, Condition, Post

Die vollständige Form mit allen drei Klauseln ist der direkte Verwandte der C-for-Schleife. Init läuft einmal vor der ersten Iteration, Condition wird vor jeder Iteration geprüft, Post läuft am Ende jeder Iteration:

Go for_classic.go
package main

import "fmt"

func main() {
    // Standard-Zählschleife
    for i := 0; i < 5; i++ {
        fmt.Println("i =", i)
    }

    // i ist hier außerhalb nicht mehr sichtbar.
    // fmt.Println(i)  // Fehler: undefined: i

    // Rückwärts
    for j := 10; j > 0; j -= 2 {
        fmt.Println("j =", j)
    }

    // Mehrere Variablen über Multi-Assignment in Init und Post
    for a, b := 0, 1; a < 20; a, b = b, a+b {
        fmt.Print(a, " ")
    }
    fmt.Println()
}
Output
i = 0
i = 1
i = 2
i = 3
i = 4
j = 10
j = 8
j = 6
j = 4
j = 2
0 1 1 2 3 5 8 13

Drei Beobachtungen:

  • Scope der Init-Variable. Wie bei if öffnet for einen impliziten Block — die in der Init-Klausel mit := deklarierten Variablen leben bis zur schließenden Klammer des Bodys und sind danach weg. Das verschmutzt den Funktions-Scope nicht.
  • Multi-Assignment in Init und Post. Mit a, b = b, a+b werden beide Werte gleichzeitig getauscht — die rechte Seite wird vollständig ausgewertet, bevor irgendetwas zugewiesen wird. Ideal für Fibonacci, Pointer-Tausch, Window-Sliding.
  • Init und Post sind optional. Du kannst eine, zwei oder alle drei Klauseln weglassen — die Semikolons bleiben dann aber stehen, solange mindestens eine Klausel da ist. for ; i < 10; i++ { ... } ist legal, wenn i außerhalb deklariert wurde.

Form 2 — While-artig mit nur einer Condition

Wenn nur die Condition übrig bleibt, ersetzt for die klassische while-Schleife — Go hat kein eigenes Keyword dafür:

Go for_while.go
package main

import "fmt"

func main() {
    // Klassisches while-Pattern
    n := 1
    for n < 100 {
        n *= 2
    }
    fmt.Println("erste Zweier-Potenz >= 100:", n)

    // Suche in einem Slice
    data := []int{3, 7, 1, 9, 4}
    i := 0
    for i < len(data) && data[i] != 9 {
        i++
    }
    if i < len(data) {
        fmt.Println("9 gefunden bei Index", i)
    }

    // Lesen, bis Sentinel erreicht
    queue := []string{"a", "b", "STOP", "c"}
    for len(queue) > 0 && queue[0] != "STOP" {
        fmt.Println("verarbeite", queue[0])
        queue = queue[1:]
    }
}
Output
erste Zweier-Potenz >= 100: 128
9 gefunden bei Index 3
verarbeite a
verarbeite b

Die while-Form ist die richtige Wahl, wenn der Loop-Index nicht der treibende Faktor ist — sondern eine externe Bedingung wie „Queue nicht leer", „Reader liefert noch Daten" oder „Convergence-Kriterium nicht erreicht".

Form 3 — Endlos mit explizitem Exit

Lässt du auch die Condition weg, läuft die Schleife unbegrenzt. Der Ausstieg passiert über break, return, panic oder os.Exit. Diese Form ist das Standard-Muster für Server-Loops, Event-Loops und Reader-Schleifen mit Sentinel:

Go for_infinite.go
package main

import (
    "bufio"
    "fmt"
    "io"
    "strings"
)

func main() {
    r := bufio.NewReader(strings.NewReader("erste\nzweite\ndritte\n"))

    // Reader-Loop — endlos, bis EOF
    for {
        line, err := r.ReadString('\n')
        if err == io.EOF {
            break
        }
        if err != nil {
            fmt.Println("Fehler:", err)
            return
        }
        fmt.Print("Zeile: ", line)
    }

    fmt.Println("fertig")
}
Output
Zeile: erste
Zeile: zweite
Zeile: dritte
fertig

for { ... } ist die idiomatische Schreibweise für „endlos". Manchmal sieht man for true { ... } — das ist legal, aber unnötig: gofmt lässt es stehen, Reviewer kürzen es zu for { ... }.

range — die idiomatische Iteration

Die range-Klausel ist Gos einheitliches Interface für Iteration über Container. Sie funktioniert über sechs Typen — Array, Slice, String, Map, Channel und (seit Go 1.22) Integer — und liefert je nach Typ einen oder zwei Werte:

Typ1. Wert2. WertBesonderheit
[]E / [N]EIndex (int)Element (Kopie!)Reihenfolge garantiert 0..len-1
stringByte-Index (int)RuneIteriert UTF-8-Codepoints, nicht Bytes
map[K]VKeyValueReihenfolge randomisiert
chan EWertTerminiert bei close(ch)
int (Go 1.22+)Zähler 0..n-1Kein zweiter Wert erlaubt
Iterator-Func (Go 1.23+)Yield-WertePull-basierte Iteratoren

Die Range-Expression wird einmal vor der Schleife ausgewertet. Spätere Mutationen am ursprünglichen Slice/Map verändern den Iterationslauf nicht — du bekommst keine „live"-Sicht.

range über Slice und Array

Bei Slices und Arrays liefert range Index und einen kopierten Wert. Das ist der häufigste Anfänger-Stolperstein: wer das Element mutieren will, muss über den Index gehen — der zweite Range-Wert ist eine reine Lese-Kopie:

Go range_slice.go
package main

import "fmt"

type Player struct {
    Name  string
    Score int
}

func main() {
    players := []Player{
        {"Alice", 10},
        {"Bob", 20},
        {"Carol", 30},
    }

    // (1) Index + Wert (Wert ist Kopie!)
    for i, p := range players {
        fmt.Printf("%d: %s hat %d\n", i, p.Name, p.Score)
    }

    // (2) Nur Index
    for i := range players {
        players[i].Score += 5 // funktioniert — mutiert das Original
    }

    // (3) Nur Wert (Index ausblenden)
    total := 0
    for _, p := range players {
        total += p.Score
    }
    fmt.Println("Summe:", total)

    // (4) Falle — Mutation am Range-Wert ist sinnlos
    for _, p := range players {
        p.Score = 0 // p ist eine Kopie, das Original bleibt
    }
    fmt.Println("nach Pseudo-Reset:", players[0].Score)
}
Output
0: Alice hat 10
1: Bob hat 20
2: Carol hat 30
Summe: 75
nach Pseudo-Reset: 15

Drei Lektionen:

  • Wert ist Kopie. Wer Struct-Felder mutieren will, geht über den Index (players[i].X = ...) oder verwendet ein Slice von Pointern ([]*Player).
  • Index ohne Wert ist die idiomatische Form, wenn der Wert nicht gebraucht wird oder mutiert werden soll.
  • for _, v := range arr auf einem großen Array kopiert das Array komplett, bevor iteriert wird — denn Arrays sind in Go Value-Types. Bei großen Arrays besser Pointer oder Slice nehmen.

range über Map — randomisierte Reihenfolge

Gos Map-Iteration ist absichtlich nicht deterministisch. Bei jedem Programmlauf — und sogar zwischen zwei Schleifen über dieselbe Map — kann die Reihenfolge eine andere sein. Das ist eine bewusste Design-Entscheidung, die verhindert, dass Code versehentlich auf eine implementierungsabhängige Ordnung baut:

Go range_map.go
package main

import (
    "fmt"
    "sort"
)

func main() {
    scores := map[string]int{
        "Alice": 10,
        "Bob":   20,
        "Carol": 30,
    }

    // (1) Reihenfolge ist UNBESTIMMT
    for name, score := range scores {
        fmt.Println(name, "->", score)
    }

    fmt.Println("---")

    // (2) Wer sortierte Ausgabe braucht: Keys extrahieren und sortieren
    keys := make([]string, 0, len(scores))
    for k := range scores {
        keys = append(keys, k)
    }
    sort.Strings(keys)
    for _, k := range keys {
        fmt.Println(k, "->", scores[k])
    }
}
Output
Bob -> 20
Alice -> 10
Carol -> 30
---
Alice -> 10
Bob -> 20
Carol -> 30

Auch erwähnenswert: Wer während der Iteration die Map mutiert, bewegt sich in undefiniertem Terrain. Die Spec sagt: gelöschte Einträge werden eventuell nicht mehr ausgeliefert, neu eingefügte eventuell schon. Verlass dich nicht darauf — sammle Änderungen in einem Slice und wende sie nach der Schleife an.

range über String — Runes statt Bytes

Bei Strings macht range etwas, was Anfänger oft überrascht: Es iteriert über Runes (Unicode-Codepoints), nicht über Bytes. Der erste Wert ist die Byte-Position des Codepoints, der zweite die rune selbst. Ein Multibyte-Zeichen springt entsprechend mehrere Bytes weiter:

Go range_string.go
package main

import "fmt"

func main() {
    s := "Go für €"

    // (1) range iteriert UTF-8-Runes
    for i, r := range s {
        fmt.Printf("Byte %d: rune %q (U+%04X)\n", i, r, r)
    }

    fmt.Println("Byte-Länge:", len(s))

    // (2) Klassische for-Schleife iteriert Bytes
    fmt.Println("--- Byte-Iteration ---")
    for i := 0; i < len(s); i++ {
        fmt.Printf("Byte %d: %d\n", i, s[i])
    }
}
Output
Byte 0: rune 'G' (U+0047)
Byte 1: rune 'o' (U+006F)
Byte 2: rune ' ' (U+0020)
Byte 3: rune 'f' (U+0066)
Byte 4: rune 'ü' (U+00FC)
Byte 6: rune 'r' (U+0072)
Byte 7: rune ' ' (U+0020)
Byte 8: rune '€' (U+20AC)
Byte-Länge: 11

Zwei Punkte zum Mitnehmen:

  • Die Index-Sprünge sind nicht 0, 1, 2, ..., sondern nach Byte-Breite des vorherigen Codepoints. ü belegt 2 Bytes, belegt 3 Bytes — danach springt der Index entsprechend weiter.
  • Bei ungültigem UTF-8 liefert range die Replacement-Rune U+FFFD und schiebt den Index um genau 1 Byte weiter. So bleibt die Iteration auch bei kaputten Daten endlich.

Wer wirklich byteweise iterieren will (etwa für reine ASCII-Verarbeitung oder Performance-Pfade), nimmt die klassische for i := 0; i < len(s); i++-Form mit s[i] — das liefert byte, nicht rune.

range über Channel — terminiert bei close

Bei Channels liefert range einen einzelnen Wert pro Iteration — den Wert, der aus dem Channel empfangen wurde. Die Schleife terminiert, sobald der Channel geschlossen und leer ist:

Go range_channel.go
package main

import "fmt"

func produce(ch chan<- int) {
    defer close(ch) // close beendet den Range-Loop beim Empfänger
    for i := 1; i <= 5; i++ {
        ch <- i * i
    }
}

func main() {
    ch := make(chan int)
    go produce(ch)

    // range terminiert automatisch nach close
    for v := range ch {
        fmt.Println("empfangen:", v)
    }

    fmt.Println("Channel geschlossen, Loop beendet")
}
Output
empfangen: 1
empfangen: 4
empfangen: 9
empfangen: 16
empfangen: 25
fertig
Channel geschlossen, Loop beendet

Drei Anmerkungen:

  • Wer den Channel nicht schließt, blockiert die Range-Schleife für immer (Deadlock, sobald keine Sender mehr da sind). Schließen ist Sender-Verantwortung — niemals der Empfänger.
  • Nil-Channel in range: Empfangen aus einem nil-Channel blockiert ewig — bei range heißt das: die Schleife wird nie etwas tun und nie zurückkehren.
  • Nur ein Range-Wert. Im Gegensatz zum direkten v, ok := <-ch bekommst du in der Range-Form kein ok — der Loop kümmert sich um die Terminierung selbst.

range über Integer (Go 1.22+) und Funktion (Go 1.23+)

Seit Go 1.22 darf der Range-Ausdruck auch ein Integer sein. Das ist Zucker für die häufige for i := 0; i < n; i++-Form, aber ohne die ergonomische Last:

Go range_int.go
package main

import "fmt"

func main() {
    // n-mal — der idiomatische Ersatz für for i := 0; i < n; i++
    for i := range 5 {
        fmt.Println("i =", i)
    }

    // Wer den Zähler nicht braucht: range ohne linke Seite
    count := 0
    for range 3 {
        count++
    }
    fmt.Println("count =", count)

    // Negative oder Null: keine Iteration
    for i := range 0 {
        fmt.Println("nicht erreicht", i)
    }
    fmt.Println("nach for range 0")
}
Output
i = 0
i = 1
i = 2
i = 3
i = 4
count = 3
nach for range 0

Eine wichtige Einschränkung: Bei range n ist nur ein Range-Wert erlaubt (der Zähler). Ein zweiter Wert wäre ein Syntax-Fehler — anders als bei Slice/Map/String.

Funktions-Iteratoren (Go 1.23+). Mit Go 1.23 kommt range über Funktionen — die Basis für Pull-basierte Iteratoren in der Standard-Library (siehe iter-Paket). Eine kurze Skizze:

Go range_func.go
package main

import "fmt"

// Iterator-Funktion: liefert Werte an einen Yield-Callback
func upTo(n int) func(yield func(int) bool) {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            if !yield(i) {
                return // Konsument hat break gemacht
            }
        }
    }
}

func main() {
    for v := range upTo(4) {
        fmt.Println("v =", v)
    }
}
Output
v = 0
v = 1
v = 2
v = 3

Details zu Iteratoren gehören in einen eigenen Artikel — hier reicht: Es gibt sie, sie sind das Fundament für iter.Seq / iter.Seq2 und neue Library-APIs wie maps.Keys, slices.All.

Die Loop-Variable seit Go 1.22 — die wichtigste Verhaltensänderung

Bis Go 1.21 war die Loop-Variable eine einzige Variable für alle Iterationen — alle Closures und Goroutinen, die sie einfingen, teilten sich denselben Speicherplatz. Das war die häufigste Bug-Quelle in echtem Go-Code. Seit Go 1.22 bekommt jede Iteration ihre eigene Variable:

Go loopvar_pre_122.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    values := []string{"a", "b", "c"}

    for _, v := range values {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(v)
        }()
    }
    wg.Wait()
}
Output
# Go 1.22+ (module declares go 1.22 oder höher):
a
b
c
# in irgendeiner Reihenfolge

# Go 1.21 oder älter (oder module declares go 1.21):
c
c
c

Die Änderung greift nur, wenn das Modul in go.mod go 1.22 oder höher deklariert. Älterer Code läuft unverändert weiter — das war Voraussetzung für die Aufnahme dieser Verhaltensänderung in eine stabile Sprache.

Vor Go 1.22 — der manuelle Workaround. Wer das alte Verhalten umgehen wollte, deklarierte die Variable im Body neu — Shadowing als Feature:

Go loopvar_workaround.go
// Pre-1.22-Workaround Variante A: Shadow im Body
for _, v := range values {
    v := v // neue v-Variable pro Iteration
    go func() { fmt.Println(v) }()
}

// Pre-1.22-Workaround Variante B: Wert als Parameter
for _, v := range values {
    go func(v string) { fmt.Println(v) }(v)
}

Diese Patterns funktionieren weiterhin und schaden auch unter Go 1.22+ nicht — sie sind nur schlicht nicht mehr nötig. In neu geschriebenem Code mit go 1.22+ kannst du die Closure direkt schreiben. Detail aus dem Blog-Post: Das Team hat 2023 mit GOEXPERIMENT=loopvar einen Vorlauf gemacht, in dem große Codebases (inkl. Kubernetes) das neue Verhalten geprüft haben — Bugs durch das alte Verhalten waren häufig, durch das neue extrem selten.

break, continue, Labels

break verlässt die innerste umgebende Schleife oder den Switch. continue springt zur Post-Klausel (klassische Form) bzw. zur nächsten Iteration. Wer aus mehreren verschachtelten Schleifen ausbrechen will, nutzt Labels:

Go break_continue.go
package main

import "fmt"

func main() {
    grid := [][]int{
        {1, 2, 3},
        {4, 5, 6},
        {7, 8, 9},
    }

outer:
    for i, row := range grid {
        for j, v := range row {
            if v == 5 {
                fmt.Printf("gefunden bei (%d, %d)\n", i, j)
                break outer // bricht beide Schleifen ab
            }
        }
    }

    // continue mit Filter
    for i := 1; i <= 10; i++ {
        if i%2 == 0 {
            continue
        }
        fmt.Print(i, " ")
    }
    fmt.Println()
}
Output
gefunden bei (1, 1)
1 3 5 7 9 

Mehr Details — zum Beispiel die Unterschiede zwischen break, continue, goto und return aus einer Schleife heraus — stehen im eigenen Artikel zu break, continue, goto.

Häufige Stolperfallen

Loop-Variable in Closure / Goroutine — pre-1.22 die Bug-Klasse Nr. 1.

Vor Go 1.22 teilten sich alle Iterationen eine einzige Variable. for _, v := range xs { go func() { use(v) }() } rief am Ende oft use(letztesElement) dreimal auf — nicht das, was du wolltest. Seit Go 1.22 hat jede Iteration ihre eigene v. Prüfe deine go.mod: go 1.22 oder höher aktiviert das neue Verhalten.

Der zweite range-Wert ist eine Kopie, kein Pointer.

for _, p := range players { p.Score++ } mutiert nichts — p ist eine Kopie der Struct. Wer das Original ändern will, geht entweder über den Index (players[i].Score++) oder iteriert über ein Slice von Pointern ([]*Player). Auch Memory-bewusst: bei großen Structs kann die Kopie teuer sein.

Map-Iteration ist absichtlich randomisiert.

Die Reihenfolge ist nicht nur „nicht garantiert", sondern aktiv zufallsbedingt — Go würfelt den Start-Slot. Wer deterministische Ausgabe braucht (Logs, Tests, JSON-Snapshots), sammelt die Keys, sortiert sie, und iteriert dann über das sortierte Slice.

range über String iteriert Runes, nicht Bytes.

Bei UTF-8-Inhalten springt der Index in Schritten der Codepoint-Bytebreite — 1, 2, 3 oder 4 Bytes. Wer byteweise verarbeiten muss, nimmt for i := 0; i < len(s); i++ { ... s[i] ... }. Ungültiges UTF-8 erzeugt U+FFFD und schiebt um 1 Byte weiter.

range über Channel terminiert nie ohne close.

for v := range ch läuft, bis der Channel geschlossen ist. Wenn niemand close(ch) ruft, blockiert der Empfänger bei leerem Channel — Deadlock-Risiko. close ist immer Aufgabe des Senders, niemals des Empfängers, und nur exakt einmal pro Channel zulässig.

Endlos-Schleife ohne sicheren Exit-Pfad.

for { ... } ohne break, return, panic oder os.Exit ist ein Hang-Bug. Besonders heimtückisch: ein break in einem select innerhalb des Loops bricht nur das select ab, nicht den for. Wer aus dem for heraus will, braucht ein Label: for { select { case ...: break outer } }.

Composite-Literal-Ambiguität in der Condition.

for p := next(); p == Point{0, 0}; p = next() { ... } ist Syntax-Fehler — der Compiler liest Point{ als Body-Anfang. Lösung: Klammern um das Literal (p == (Point{0, 0})) oder eine vorgeschobene Variable. Dieselbe Falle hatte schon if und switch.

Off-by-one — < vs. <=.

for i := 0; i <= len(s); i++ { s[i] } greift einen Index zu weit zu und löst eine Panic aus. Idiomatisch ist i < len(s), weil Indizes 0-basiert sind und der letzte gültige Index len-1 ist. Bei Range-Loops umgehst du das Problem komplett — range läuft genau über die gültigen Indizes.

Range-Expression wird einmal ausgewertet.

for i, v := range slice { slice = nil } läuft trotz slice = nil zu Ende — die Schleife arbeitet mit der ursprünglichen Slice-Header-Kopie. Auch len() in der klassischen Form (for i := 0; i < len(s); i++) ist OK: len ist O(1), und bei stabiler Länge gibt es keinen Effizienz- oder Korrektheits-Verlust.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Kontrollstrukturen

Zur Übersicht