switch Statements

A switch statement lets you compare a value against a list of possible matches and run the code for whichever one matches, instead of writing a long chain of if/else if statements. Go’s switch is more flexible than the one you may know from C, Java, or JavaScript: cases don’t need to be constants, there is no automatic fall-through between cases, and a single switch can even branch on the dynamic type stored inside an interface value. Understanding switch well makes a huge amount of everyday Go code — parsing, state machines, error handling, type dispatch — much easier to read and write.

Overview / How it works

Go actually gives you three shapes of switch, and it helps to think of them as one construct with optional pieces:

  • Expression switch with a tagswitch tag { case v1: ... } compares tag against each case value with ==, top to bottom, and runs the first match.
  • Tagless switchswitch { case cond1: ... } omits the tag entirely. Go treats this the same as switch true, so each case is a boolean expression, and it behaves like a chain of if/else if statements — often more readable than a long chain when there are several independent conditions.
  • Type switchswitch v := x.(type) { case int: ... } branches on the concrete type currently stored in an interface value.

Any of these can also start with an init statement, a short simple statement (usually a := declaration) that runs once before the switch is evaluated, separated from the tag by a semicolon: switch x := compute(); x { ... }. The variable declared there is scoped to the whole switch, including every case.

The single biggest difference from C-family switches is that Go’s cases do not fall through by default. Each case implicitly ends with a break — once a matching case’s statements finish, control jumps straight past the whole switch. If you genuinely want execution to continue into the next case’s statements, you say so explicitly with the fallthrough keyword as the last statement of a case. This design choice removes an entire category of classic C bugs where a forgotten break silently falls into the next case.

Another difference: case values don’t have to be compile-time constants. They can be any expression of a type comparable to the tag, evaluated at runtime, in source order, stopping at the first match — which is why a case can be something like score >= 80 rather than just a literal. That said, for switches over integers or strings with many cases, the Go compiler is free to generate more efficient code under the hood, such as a binary search or a jump table, instead of literally testing every case in sequence. This is purely an optimization; the observable behavior — test in order, run the first match — never changes.

A type switch is one of the few places in Go where you explicitly ask an interface value what concrete type it currently holds. Because Go interfaces are satisfied implicitly (a type never declares \”implements SomeInterface\”; it just needs the right methods), you often don’t know a variable’s concrete type at compile time — a type switch lets you recover it safely at runtime, case by case, without a failed type assertion panicking your program.

Finally, each case (and the switch’s init statement) introduces its own implicit block scope. A variable declared with := inside a case, or in the switch’s init statement, exists only for that switch — it does not leak out, and if its name matches an outer variable, it shadows that outer variable rather than modifying it. That’s a common source of confusion covered in Common Mistakes below.

Syntax

The general form of an expression switch:

switch optionalInit; optionalTag {
case value1[, value2...]:
    // statements
case value3:
    // statements
    fallthrough
default:
    // statements
}
Part Meaning
optionalInit An optional simple statement, usually a short variable declaration (:=), that runs once before the switch is evaluated. Scoped to the entire switch.
optionalTag An optional expression compared against each case. If omitted, the switch behaves like switch true, and each case is a boolean condition — effectively an if/else-if chain.
case value1, value2: A case may list several comma-separated values; it matches if the tag equals any of them (logical OR).
fallthrough As the last statement in a case, unconditionally transfers control into the next case’s statements without checking that case’s condition. Cannot be used in the switch’s final case.
default Runs if no case matches. Optional, and may be placed anywhere in the switch (conventionally last).

Type switch syntax

A type switch uses the special x.(type) form, valid only inside a switch statement:

switch v := x.(type) {
case Type1:
    // v has type Type1 here
case Type2, Type3:
    // v has x's original interface type here
case nil:
    // x holds no value at all
default:
    // v has x's original interface type here
}

Inside a case listing exactly one type, v has that concrete type. In a case listing multiple types (or default), v keeps the original interface type, since its concrete type isn’t narrowed to a single possibility.

Examples

Example 1: A basic expression switch

package main

import "fmt"

func main() {
	day := 3
	switch day {
	case 1:
		fmt.Println("Monday")
	case 2:
		fmt.Println("Tuesday")
	case 3:
		fmt.Println("Wednesday")
	case 4:
		fmt.Println("Thursday")
	case 5:
		fmt.Println("Friday")
	default:
		fmt.Println("Weekend")
	}
}

Output:

Wednesday

The tag day is compared against each case in order. day == 3 matches the third case, that branch’s single statement runs, and the switch ends immediately — no other case is checked, and no explicit break is needed.

Example 2: A tagless switch (an if/else-if replacement)

package main

import "fmt"

func main() {
	score := 82
	switch {
	case score >= 90:
		fmt.Println("Grade: A")
	case score >= 80:
		fmt.Println("Grade: B")
	case score >= 70:
		fmt.Println("Grade: C")
	default:
		fmt.Println("Grade: F")
	}
}

Output:

Grade: B

With no tag, Go evaluates each case as a boolean condition, top to bottom, and runs the first one that’s true. score >= 90 is false, but score >= 80 is true, so \”Grade: B\” prints and the remaining cases are never evaluated — order matters here just as it would in an if/else if chain.

Example 3: An init statement with multiple values per case

package main

import "fmt"

func main() {
	switch day := 6; day {
	case 1, 2, 3, 4, 5:
		fmt.Println("Weekday")
	case 6, 7:
		fmt.Println("Weekend")
	default:
		fmt.Println("Invalid day")
	}
}

Output:

Weekend

The init statement day := 6 declares day scoped to the switch, then uses it as the tag. The case 6, 7 matches because a comma-separated case list is a logical OR — it’s equivalent to writing case 6: ... case 7: ... with identical bodies, but without duplicating the code.

Example 4: A type switch over an any parameter

package main

import "fmt"

func describe(i any) string {
	switch v := i.(type) {
	case int:
		return fmt.Sprintf("int with value %d", v)
	case string:
		return fmt.Sprintf("string of length %d", len(v))
	case bool:
		return fmt.Sprintf("bool: %t", v)
	case nil:
		return "nil value"
	default:
		return fmt.Sprintf("unhandled type %T", v)
	}
}

func main() {
	fmt.Println(describe(42))
	fmt.Println(describe("hello"))
	fmt.Println(describe(true))
	fmt.Println(describe(3.14))
}

Output:

int with value 42
string of length 5
bool: true
unhandled type float64

describe accepts any, so its dynamic type is unknown at compile time. The type switch checks it against int, string, and bool in turn; inside each matching case, v is automatically that concrete type, so len(v) works directly on the string case without an extra type assertion. The float64 argument matches none of the listed types and falls to default, where %T prints its actual type.

How it works step by step

fallthrough is the piece that trips people up most, so it’s worth tracing by hand:

package main

import "fmt"

func main() {
	n := 6
	switch {
	case n%2 == 0 && n%3 == 0:
		fmt.Println("divisible by 6")
		fallthrough
	case n%2 == 0:
		fmt.Println("even")
	case n%2 != 0:
		fmt.Println("odd")
	}
}

Output:

divisible by 6
even
  1. The switch has no tag, so Go treats it as switch true and tests each case’s boolean condition in order.
  2. The first case, n%2 == 0 && n%3 == 0, evaluates to true for n = 6, so its statements run, printing "divisible by 6".
  3. fallthrough is the last statement in that case. It unconditionally jumps into the next case’s statements — note that the next case’s own condition (n%2 == 0) is never checked; fallthrough ignores it entirely.
  4. The second case’s statement runs, printing "even". Since this case has no fallthrough, the switch ends here.
  5. The third case, n%2 != 0, is never evaluated at all, even though nothing so far confirmed it was false — once a case’s body finishes without fallthrough, the whole switch is done.

Common Mistakes

Mistake 1: Putting fallthrough in the final case

Because Go switches don’t fall through automatically, it’s tempting to think you can always add fallthrough defensively — but it’s illegal in the switch’s last case, since there’s nothing after it to fall into:

x := 2
switch x {
case 1:
	fmt.Println("one")
default:
	fmt.Println("other")
	fallthrough
}
// compile error: cannot fallthrough final case in switch

The fix is simply to remove the stray fallthrough — if default is genuinely the last thing that should run, there is nothing left to fall into:

package main

import "fmt"

func main() {
	x := 2
	switch x {
	case 1:
		fmt.Println("one")
	default:
		fmt.Println("other")
	}
}

Output:

other

Mistake 2: Using a non-nil slice (or map, or func) as a case value

Slices, maps, and functions are not comparable in Go except to nil. Switching on one and listing a non-nil literal as a case fails to compile:

data := []int{1, 2, 3}
switch data {
case []int{1, 2, 3}:
	fmt.Println("matched")
}
// compile error: invalid case []int{...} in switch (can only compare slice data to nil)

Switch instead on something comparable derived from the slice, such as its length, or restructure the logic around if with a helper like reflect.DeepEqual if you truly need element-by-element comparison:

package main

import "fmt"

func main() {
	data := []int{1, 2, 3}
	switch len(data) {
	case 0:
		fmt.Println("empty")
	case 3:
		fmt.Println("exactly three elements")
	default:
		fmt.Println("some other size")
	}
}

Output:

exactly three elements

Mistake 3: Shadowing an outer variable in the switch’s init statement

Because the init statement’s := declares a variable scoped to the switch, reusing an outer variable’s name there creates a brand-new, separate variable — it does not update the outer one, which surprises people expecting mutation:

package main

import "fmt"

func nextCount(n int) int {
	return n + 1
}

func main() {
	count := 5
	switch count := nextCount(count); count {
	case 6:
		fmt.Println("incremented to", count)
	}
	fmt.Println("outer count is still", count)
}

Output:

incremented to 6
outer count is still 5

The count inside the switch’s init statement shadows the outer count; the outer variable never changes. If the intent was to update the outer variable, assign to it directly instead of redeclaring a new one with the same name:

package main

import "fmt"

func nextCount(n int) int {
	return n + 1
}

func main() {
	count := 5
	count = nextCount(count)
	switch count {
	case 6:
		fmt.Println("incremented to", count)
	}
	fmt.Println("outer count is now", count)
}

Output:

incremented to 6
outer count is now 6

Best Practices

  • Prefer a tagless switch over a long if/else if chain once you have more than two or three independent conditions — it reads top to bottom more clearly.
  • Group related values with a comma-separated case list (case 6, 7:) instead of duplicating a case body.
  • Avoid fallthrough unless you genuinely need to run a subsequent case’s code unconditionally; it bypasses that case’s own condition and is easy to misuse. Most switches never need it.
  • Add a default case even when you believe every value is covered — it documents intent and protects against unexpected input, especially values coming from outside your program (user input, deserialized data, API responses).
  • Use the init-statement form, switch x := compute(); x { ... }, to keep a temporary value tightly scoped to the switch instead of leaking it into the surrounding function.
  • In type switches, order more specific or more commonly expected types earlier, and always include a default to handle types you didn’t anticipate.
  • Keep switch bodies focused; if a case’s logic grows large, extract it into its own named function and call that function from the case.

Practice Exercises

  • Write a function sizeCategory(n int) string using a tagless switch that returns "small" for n < 10, "medium" for n < 100, and "large" otherwise. Call it with a few different values and print the results.
  • Write a function that accepts an any parameter and uses a type switch to print whether the underlying value is an int, a float64, a string, or "unknown type" for anything else. Test it with at least four different argument types.
  • Rewrite a chain of if/else if comparisons that checks an integer dayOfWeek (1–7) and prints "Weekday" or "Weekend" as a single switch using comma-separated case values (case 1, 2, 3, 4, 5: and case 6, 7:), plus a default that prints "Invalid day" for anything else.

Summary

  • Go’s switch comes in three shapes: expression switch (with a tag), tagless switch (boolean cases, like if/else-if), and type switch (branches on an interface’s dynamic type).
  • Cases do not fall through automatically — each case implicitly breaks after its statements finish, unlike C, Java, or JavaScript.
  • fallthrough must be explicit, must be the last statement in a case, and cannot appear in the switch’s final case; it skips the next case’s own condition entirely.
  • A case can list multiple comma-separated values, matching if the tag equals any of them.
  • Case values can be arbitrary runtime expressions, not just compile-time constants, and are tested in source order until the first match.
  • A switch’s optional init statement and each case introduce their own scope — reusing an outer variable’s name there shadows it rather than mutating it.
  • A type switch (x.(type)) is the idiomatic way to inspect an interface value’s concrete type at runtime, since Go interfaces have no explicit \”implements\” declaration to check against at compile time.