Gos switch ist nicht das langweilige switch aus C. Es hat keine implicit fallthrough-Falle, akzeptiert mehrere Werte pro Case, lebt mit einer optionalen Init-Klausel und kennt eine eigene Sonderform — den Type Switch — die aus einem Interface-Wert seinen dynamischen Typ herausfischt und gleichzeitig in einer typisierten Variable bereitstellt. Dazu gibt es das Tag-lose switch, das eine lange if-else if-Kette in eine flache, gut lesbare Tabelle verwandelt. Dieser Artikel arbeitet alle Formen formal durch — Grammar, Scoping, fallthrough, break, Type-Switch-Mechanik — und sammelt am Ende die Stolperfallen, die in echten Codebases regelmäßig auftauchen.

Zwei switch-Familien: Expression-Switch und Type-Switch

Die Go-Spec teilt switch in zwei syntaktisch ähnliche, semantisch verschiedene Formen auf — den Expression-Switch (Vergleich auf Werte) und den Type-Switch (Vergleich auf dynamische Typen eines Interface-Werts):

EBNF SwitchStmt (Go-Spec)
SwitchStmt     = ExprSwitchStmt | TypeSwitchStmt .

ExprSwitchStmt = "switch" [ SimpleStmt ";" ] [ Expression ] "{" { ExprCaseClause } "}" .
ExprCaseClause = ExprSwitchCase ":" StatementList .
ExprSwitchCase = "case" ExpressionList | "default" .

TypeSwitchStmt  = "switch" [ SimpleStmt ";" ] TypeSwitchGuard "{" { TypeCaseClause } "}" .
TypeSwitchGuard = [ identifier ":=" ] PrimaryExpr "." "(" "type" ")" .
TypeCaseClause  = TypeSwitchCase ":" StatementList .
TypeSwitchCase  = "case" TypeList | "default" .
TypeList        = Type { "," Type } .

Aus dieser Grammar ergeben sich vier praktisch relevante Varianten:

FormKopfWas wird verglichen?
Expression-Switch mit Tagswitch x { ... }x gegen Case-Werte
Tag-loses Expression-Switchswitch { ... }Boolesche Conditions pro Case
Expression-Switch mit Initswitch v := f(); v { ... }v gegen Case-Werte, Init wie bei if
Type-Switchswitch v := x.(type) { ... }Dynamischer Typ von x gegen Case-Typen

Drei Eigenschaften gelten für alle Formen — sie heben Go von C/Java/JavaScript ab:

  • Kein implizites fallthrough. Jede Case-Branche endet automatisch. Wer durchfallen will, schreibt fallthrough explizit.
  • Mehrere Werte pro Case erlaubt. case 1, 2, 3: ersetzt drei einzelne Cases.
  • Jede Case-Branche ist ein eigener Block. Variablen aus case 1: sind in case 2: nicht sichtbar.

Klassische Form mit Tag — switch x { case ... }

Die häufigste Form: ein Ausdruck im Kopf, eine Liste von Case-Werten darunter. Der Tag wird genau einmal ausgewertet, dann mit den Case-Werten von oben nach unten verglichen — der erste Treffer gewinnt:

Go switch_tag.go
package main

import "fmt"

func dayKind(day string) string {
    switch day {
    case "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag":
        return "Werktag"
    case "Samstag", "Sonntag":
        return "Wochenende"
    default:
        return "unbekannt"
    }
}

func main() {
    for _, d := range []string{"Montag", "Samstag", "Funday"} {
        fmt.Printf("%-8s -> %s\n", d, dayKind(d))
    }
}
Output
Montag   -> Werktag
Samstag  -> Wochenende
Funday   -> unbekannt

Beobachtungen:

  • Mehrere Werte pro Case werden durch Komma getrennt. Das ist ein logisches OR, kein AND — case "Montag", "Dienstag": matcht, wenn der Tag einer dieser Werte ist.
  • default ist optional. Steht idiomatisch zuletzt, darf aber syntaktisch überall stehen. Genau einer pro switch.
  • Kein break nötig. Die Branche endet mit dem nächsten case oder der schließenden Klammer.

Die Tag-Form ist nicht auf konstante Werte beschränkt — Case-Ausdrücke können beliebige Expressions sein, müssen aber vergleichbar mit dem Tag-Typ sein (mit ==). Konstanten-Cases werden zur Compile-Zeit auf Duplikate geprüft:

Go switch_duplicates.go
// FEHLER: duplicate case 1 in switch
// switch n {
// case 1:
//     // ...
// case 1:
//     // ...
// }

// OK — Cases sind Ausdrücke, der Compiler kann Dynamik nicht prüfen
switch n {
case f():
    // ...
case f():
    // bei gleichem Rückgabewert gewinnt der erste Case
}

Tag-loses switch — der saubere if-else-Ersatz

Lässt du den Tag weg, wird switch zu einer Tabelle aus Bedingungen. Jeder Case ist dann ein boolescher Ausdruck — der erste, der true ergibt, wird ausgeführt. Implicit ist der Tag dabei true, das heißt switch { case cond: ... } ist semantisch identisch zu switch true { case cond: ... }.

Go switch_tagless.go
package main

import "fmt"

func grade(score int) string {
    switch {
    case score >= 90:
        return "A"
    case score >= 75:
        return "B"
    case score >= 60:
        return "C"
    case score >= 0:
        return "F"
    default:
        return "ungültig"
    }
}

func main() {
    for _, s := range []int{95, 80, 65, 40, -1} {
        fmt.Printf("%3d -> %s\n", s, grade(s))
    }
}
Output
 95 -> A
 80 -> B
 65 -> C
 40 -> F
 -1 -> ungültig

Das ist die idiomatische Alternative zu einer langen if-else if-Kette. Vorteile:

  • Die Bedingungen stehen in einer optisch flachen Tabelle — kein Treppen-Layout.
  • default markiert den Fallback eindeutig — bei if/else if musste das ein freier else-Zweig leisten.
  • Die Reihenfolge der Cases bleibt entscheidend (top-to-bottom, erster Treffer gewinnt), aber das Layout zeigt das klar.

Faustregel: Ab drei else if-Zweigen wechselt man auf Tag-loses switch.

Init-Klausel — switch v := f(); v { ... }

Wie if und for erlaubt switch eine optionale Init-Klausel vor dem Tag, abgetrennt durch ein Semikolon. Die dort deklarierte Variable lebt im gesamten switch-Block — also in allen Cases und im optionalen default. Außerhalb ist sie weg.

Go switch_init.go
package main

import (
    "fmt"
    "time"
)

func greetingFor(t time.Time) string {
    switch h := t.Hour(); {
    case h < 6:
        return "noch Nacht"
    case h < 12:
        return "Guten Morgen"
    case h < 18:
        return "Guten Tag"
    default:
        return "Guten Abend"
    }
}

func main() {
    for _, h := range []int{2, 9, 14, 20} {
        t := time.Date(2026, 5, 16, h, 0, 0, 0, time.UTC)
        fmt.Printf("%02d Uhr -> %s\n", h, greetingFor(t))
    }
}
Output
02 Uhr -> noch Nacht
09 Uhr -> Guten Morgen
14 Uhr -> Guten Tag
20 Uhr -> Guten Abend

Drei Punkte zur Init-Klausel:

  • Sie ist syntaktisch ein SimpleStatement — also :=, =, Funktionsaufruf, x++/x--, Send. Keine Blockstatements.
  • Sie läuft einmal, vor jedem Case-Vergleich. Seiteneffekte landen nur einmal, nicht pro Case.
  • Sie kombiniert sich mit jeder Tag-Form: switch v := f(); v { ... } (mit Tag), switch v := f(); { ... } (tag-los), switch v := x.(type) { ... } (Type-Switch — Init ist hier der Type-Switch-Guard selbst).

fallthrough — explizit, nie versehentlich

Gos default-Verhalten ist das Gegenteil von C: Jede Case-Branche endet automatisch. Wer durchfallen will, schreibt fallthrough als letztes Statement der Branche — der Compiler erzwingt diese Position.

Go switch_fallthrough.go
package main

import "fmt"

func describe(n int) {
    switch n {
    case 1:
        fmt.Println("eins")
        fallthrough
    case 2:
        fmt.Println("eins oder zwei")
        fallthrough
    case 3:
        fmt.Println("eins, zwei oder drei")
    case 4:
        fmt.Println("vier (eigene Branche)")
    default:
        fmt.Println("etwas anderes")
    }
}

func main() {
    describe(1)
    fmt.Println("---")
    describe(2)
    fmt.Println("---")
    describe(4)
}
Output
eins
eins oder zwei
eins, zwei oder drei
---
eins oder zwei
eins, zwei oder drei
---
vier (eigene Branche)

Drei Eigenheiten von fallthrough:

  • Übergibt unbedingt an die nächste Case-Branche. Der Case-Ausdruck der nächsten Branche wird nicht neu geprüft — das Statement-List dort läuft so oder so.
  • Muss letztes Statement der Branche sein. Davor return, break oder Code dahinter ist Compile-Fehler.
  • Im Type-Switch verboten. fallthrough wäre dort sinnlos, weil der typisierte Wert in der nächsten Branche einen anderen Typ hätte.

In echtem Code ist fallthrough selten — meistens lässt sich das gleiche Ergebnis sauberer durch mehrere Werte in einem Case (case 1, 2, 3:) oder eine kleine Hilfsfunktion erreichen.

break — selten, aber manchmal nötig

Weil Cases nicht implizit durchfallen, brauchst du break in switch nicht zum Beenden einer Branche. Es hat trotzdem einen sinnvollen Einsatz: den vorzeitigen Abbruch einer Branche aus einem inneren if heraus.

Go switch_break.go
package main

import "fmt"

func process(items []int, target int) {
    switch len(items) {
    case 0:
        fmt.Println("leer")
    default:
        for _, v := range items {
            if v == target {
                fmt.Println("gefunden:", v)
                break // bricht NUR die for-Schleife ab, nicht den switch
            }
        }
        fmt.Println("Suche fertig")
    }
}

func main() {
    process([]int{}, 42)
    process([]int{1, 2, 42, 99}, 42)
}
Output
leer
gefunden: 42
Suche fertig

Wichtig: break bezieht sich auf die innerste umschließende for-, switch- oder select-Anweisung. Das obige Beispiel zeigt die Falle: break innerhalb der for bricht die Schleife ab, nicht den switch. Wer den switch von innen heraus verlassen will, braucht ein Label:

Go switch_break_label.go
package main

import "fmt"

func main() {
    items := []int{1, 2, 42, 99}

Outer:
    switch len(items) {
    case 0:
        fmt.Println("leer")
    default:
        for _, v := range items {
            if v == 42 {
                fmt.Println("Treffer — switch verlassen")
                break Outer // bricht den switch ab, nicht die for
            }
        }
        fmt.Println("würde laufen, wenn break Outer nicht griff")
    }

    fmt.Println("nach dem switch")
}
Output
Treffer — switch verlassen
nach dem switch

break Outer springt aus dem switch heraus. Ohne Label wäre nur die for betroffen, und die abschließende fmt.Println("würde laufen, ...") hätte trotzdem gefeuert.

Type Switch — switch v := x.(type) { ... }

Der Type-Switch ist Gos idiomatischer Weg, aus einem Interface-Wert seinen dynamischen Typ herauszufischen — und gleichzeitig eine korrekt typisierte Variable zu bekommen. Er ist die strukturierte Variante einer Kaskade aus if _, ok := x.(T); ok { ... }-Type-Assertions.

Go type_switch_basic.go
package main

import "fmt"

func describe(x any) {
    switch v := x.(type) {
    case nil:
        fmt.Println("ist nil")
    case bool:
        fmt.Printf("bool: %v\n", v)
    case int:
        fmt.Printf("int: %d (verdoppelt %d)\n", v, v*2)
    case string:
        fmt.Printf("string: %q (Länge %d)\n", v, len(v))
    case []byte:
        fmt.Printf("byte-Slice der Länge %d\n", len(v))
    default:
        fmt.Printf("unbekannter Typ: %T\n", v)
    }
}

func main() {
    describe(nil)
    describe(true)
    describe(42)
    describe("hallo")
    describe([]byte{1, 2, 3})
    describe(3.14)
}
Output
ist nil
bool: true
int: 42 (verdoppelt 84)
string: "hallo" (Länge 5)
byte-Slice der Länge 3
unbekannter Typ: float64

Wichtig dabei:

  • Der Guard x.(type) ist ausschließlich im Kopf eines switch erlaubt — er ist keine normale Type-Assertion.
  • x muss Interface-Typ sein. Über konkrete Typen kann man keinen Type-Switch fahren — das wäre Tautologie, der Typ steht ja schon zur Compile-Zeit fest.
  • Die deklarierte Variable (hier v) hat in jeder Case-Branche den dort gelisteten Typ. In der case int:-Branche ist v ein int, in case string: ein string, in default: der ursprüngliche Interface-Typ.
  • case nil: fängt den nil-Interface-Fall ab. Praktisch wichtig — sonst landet nil in default.

Der Type-Switch funktioniert auch ohne benannte Variable: switch x.(type) { case int: ... }. Dann hast du keinen typisierten Wert, sondern nur den Typ-Vergleich. Selten nützlich — meistens willst du den Wert auch.

Type Switch mit Mehrfach-Cases — der Interface-Typ-Sonderfall

Wenn ein case mehrere Typen listet, hat die deklarierte Variable nicht den jeweils gematchten Typ, sondern den Interface-Typ des Type-Switch-Guards. Das ist nötig, weil die Variable sonst in der Branche zwei verschiedene Typen gleichzeitig haben müsste.

Go type_switch_multi.go
package main

import "fmt"

func numericInfo(x any) {
    switch v := x.(type) {
    case int, int32, int64:
        // v hat hier den Typ `any` — NICHT int/int32/int64!
        // arithmetische Operationen direkt auf v sind nicht möglich.
        fmt.Printf("ganzzahlig (Interface-Typ): %v\n", v)
    case float32, float64:
        fmt.Printf("gleitkomma (Interface-Typ): %v\n", v)
    case int8:
        // Einziger Typ — v ist hier int8
        fmt.Printf("int8 (konkret): %d, verdoppelt: %d\n", v, v*2)
    default:
        fmt.Printf("kein numerischer Typ: %T\n", v)
    }
}

func main() {
    numericInfo(42)
    numericInfo(int32(7))
    numericInfo(int8(3))
    numericInfo(3.14)
    numericInfo("nope")
}
Output
ganzzahlig (Interface-Typ): 42
ganzzahlig (Interface-Typ): 7
int8 (konkret): 3, verdoppelt: 6
gleitkomma (Interface-Typ): 3.14
kein numerischer Typ: string

Konsequenz fürs Design: Wenn du den typisierten Wert brauchst (z. B. um arithmetisch weiterzurechnen), verteile die Typen auf separate Cases. Wenn dir der Typ-Bucket genügt (z. B. um eine Logmeldung zu drucken), darfst du gruppieren.

Wer den konkreten Typ trotzdem braucht, kann innerhalb der Branche eine nested Type-Assertion machen:

Go type_switch_nested.go
switch v := x.(type) {
case int, int32, int64:
    // v ist any — re-asserten
    switch n := v.(type) {
    case int:
        fmt.Println("int:", n*2)
    case int32:
        fmt.Println("int32:", n*2)
    case int64:
        fmt.Println("int64:", n*2)
    }
}

Doppelter Aufwand — meistens ist das ein Signal, die Cases gleich auseinanderzuziehen.

Praxis-Patterns — wann switch glänzt

Drei wiederkehrende Einsatzgebiete, in denen switch (Expression oder Type) klar gewinnt:

State-Machine. Endliche Zustände, klare Übergänge — Tag-Form mit default als „unmöglicher Zustand":

Go state_machine.go
package main

import "fmt"

type State int

const (
    StateIdle State = iota
    StateRunning
    StatePaused
    StateDone
)

func (s State) next(event string) State {
    switch s {
    case StateIdle:
        if event == "start" {
            return StateRunning
        }
    case StateRunning:
        switch event {
        case "pause":
            return StatePaused
        case "finish":
            return StateDone
        }
    case StatePaused:
        if event == "resume" {
            return StateRunning
        }
    case StateDone:
        // terminaler Zustand — keine Übergänge
    }
    return s
}

func main() {
    s := StateIdle
    for _, e := range []string{"start", "pause", "resume", "finish"} {
        s = s.next(e)
        fmt.Printf("%-7s -> %d\n", e, s)
    }
}
Output
start   -> 1
pause   -> 2
resume  -> 1
finish  -> 3

Klassifizierung über Bereiche. Tag-loses switch mit Range-Conditions — sauberer als drei else if:

Go classify_size.go
func classifySize(n int) string {
    switch {
    case n < 0:
        return "negativ"
    case n == 0:
        return "null"
    case n < 100:
        return "klein"
    case n < 10_000:
        return "mittel"
    default:
        return "groß"
    }
}

Polymorphismus über Type-Switch. Wenn ein Interface mehrere konkrete Implementierungen hat und der Verarbeiter typabhängig reagieren muss — etwa beim Walk durch einen AST oder einen JSON-Wert:

Go json_walk.go
package main

import (
    "encoding/json"
    "fmt"
)

func walk(prefix string, v any) {
    switch x := v.(type) {
    case map[string]any:
        for k, child := range x {
            walk(prefix+"."+k, child)
        }
    case []any:
        for i, child := range x {
            walk(fmt.Sprintf("%s[%d]", prefix, i), child)
        }
    case string:
        fmt.Printf("%s = %q\n", prefix, x)
    case float64:
        fmt.Printf("%s = %g\n", prefix, x)
    case bool:
        fmt.Printf("%s = %v\n", prefix, x)
    case nil:
        fmt.Printf("%s = null\n", prefix)
    default:
        fmt.Printf("%s = ? (%T)\n", prefix, x)
    }
}

func main() {
    raw := `{"name":"alice","age":30,"tags":["a","b"]}`
    var v any
    _ = json.Unmarshal([]byte(raw), &v)
    walk("root", v)
}
Output
root.name = "alice"
root.age = 30
root.tags[0] = "a"
root.tags[1] = "b"

Das ist klassischer Type-Switch-Code: encoding/json liefert beim Unmarshal in any exakt die sechs JSON-Typen — und der Type-Switch dekomponiert sie wieder.

Was Go bewusst NICHT macht — kein Pattern Matching

Wer aus Rust, Swift, Scala oder OCaml kommt, hat den Reflex, switch als Pattern-Match zu lesen. Das ist in Go nicht so:

  • Cases sind Werte oder Typen, keine Patterns. Du kannst nicht case Point{X: 0, Y: _} schreiben — das wäre ein Struct-Pattern, das Go nicht kennt.
  • Keine Guards an Cases. case n where n > 0: gibt es nicht. Wer das will, nimmt Tag-loses switch und packt die Bedingung in den Case-Ausdruck.
  • Keine Bindings im Pattern. Im Type-Switch bindet v := x.(type) einmal — pro Branche existiert die Variable mit dem passenden Typ, aber sie zerlegt die Struktur nicht weiter.

Pragmatisch heißt das: Wer Pattern-Match-Stil sucht, kombiniert switch, Type-Assertion und kleine Hilfsfunktionen. Das wird selten so elegant wie in Rust, ist aber lesbar und vollständig.

Häufige Stolperfallen

fallthrough wird oft vergessen — oder unnötig genutzt.

Gos Default ist „Branche endet automatisch". Wer aus C kommt, vergisst das anfangs nicht, sondern erwartet das Gegenteil — und wundert sich, warum case 1: case 2: nicht „matcht für 1 und 2". Lösung: case 1, 2: (Komma-OR). fallthrough ist nur für die seltene Sequenz „nach Case 1 auch Code von Case 2 ausführen, ungeprüft" gedacht — meistens umschreibt eine Hilfsfunktion das sauberer.

default kann überall stehen — idiomatisch ist es zuletzt.

Syntaktisch ist default einfach noch ein Case. Er darf ganz oben, in der Mitte oder unten stehen — die Ausführung ist davon unabhängig, weil default erst greift, wenn kein anderer Case matcht. Lesbar ist nur die Position zuletzt — alles andere irritiert Code-Reviewer.

Kein Pattern Matching wie in Rust oder Swift — Cases sind Werte/Typen.

case Point{0, 0}: matcht keinen Struct-Pattern — der Compiler liest das als Composite-Literal-Wert und vergleicht mit ==. Destructuring (case Point{X: x, Y: 0}:) gibt es gar nicht. Wer Strukturen zerlegen will, packt das in eine Hilfsfunktion oder in if-Conditions.

Type-Switch mit Mehrfach-Typ-Case: v hat dann Interface-Typ.

case int, int64: macht v zum Interface-Typ des Guards (meist any), nicht zum gematchten konkreten Typ — arithmetische Operationen schlagen fehl. Wer den konkreten Typ braucht, listet die Typen in eigenen Cases.

case a, b, c: ist OR, nicht AND.

Mehrere Werte in einem Case sind eine Oder-Liste. case 1, 2: matcht, wenn der Tag 1 oder 2 ist. Wer „matcht, wenn beide Bedingungen gelten" sucht, kombiniert das in einem booleschen Ausdruck in einem Tag-losen switch.

Composite-Literal-Ambiguität im switch-Tag.

switch x { case Point{0, 0}: ... } ist okay — aber switch Point{0, 0} { case ...: ... } wird vom Parser falsch zerlegt, weil die geschweifte Klammer als Body-Beginn gelesen wird. Lösung: Klammern (switch (Point{0, 0}) { ... }) oder vorher einer Variable zuweisen.

Type-Switch nur über Interfaces — nicht über konkrete Typen.

switch v := s.(type) mit s als string ist Compile-Fehler — der Typ steht zur Compile-Zeit fest, es gibt nichts zu unterscheiden. Type-Switch macht nur Sinn, wenn der statische Typ ein Interface ist (oft any).

Case-Reihenfolge bei Type-Switch — Interfaces nach konkreten Typen.

Wenn ein Type-Switch ein Interface (z. B. error) und einen konkreten Typ, der das Interface implementiert (z. B. *myError), gleichzeitig prüft, muss der konkretere Typ zuerst kommen. Cases werden top-to-bottom geprüft — sonst schluckt der Interface-Case alle Implementierungen, bevor der konkrete drankommt.

Duplikate Case-Werte sind Compile-Fehler bei konstanten Cases.

case 1: ... case 1: ... bricht den Build mit duplicate case 1 in switch. Bei dynamischen Case-Ausdrücken (Funktionsaufrufen) kann der Compiler das nicht prüfen — dort gewinnt einfach der erste matchende, der zweite ist toter Code.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Kontrollstrukturen

Zur Übersicht