Dispatch Without the Copy-Paste Chain
A status-code handler grows one branch at a time — if code == 200 … else if code == 404 … — until the test function is a hundred lines of chained comparisons repeating the same value. The switch exists to say: evaluate this once, then pick the matching case.
func classify(code int) string {
switch code {
case 200, 201, 204:
return "success"
case 400, 403:
return "client error"
case 404:
return "not found"
case 500, 502, 503:
return "server error"
default:
return "unknown"
}
}
Four properties are worth stating precisely, because each one is a difference from the switch you may have met elsewhere — and from the if/else chain it replaces:
The tag is evaluated exactly once. switch code computes the value, then compares it down the case list. You may also omit the tag entirely and compare each case against true — the form in the next chapter — but whether tagged or not, there is one evaluation, not one per branch.
Cases need not be constants. C demands constant integer labels; Go compares arbitrary expressions of a matching type:
switch time.Now().Weekday() {
case time.Saturday, time.Sunday:
// weekend — time.Saturday is a constant, but the tag could be anything
}
Anything comparable works as a case value — strings, runes, enumerated types — as long as each case expression has the same type as the tag (the same comparability rules from Operators and Expressions apply: no slices, no maps).
There is no implicit fallthrough. Execution enters the matching case and leaves the switch at its end, even when that case's body is empty. An empty case 3: does not mean "also 3" — grouping is spelled with commas: case 1, 2, 3:. This single rule removes the most common switch bug in other languages, where a forgotten break silently runs the next branch's code.
default is optional and can sit anywhere. Convention puts it last so the eye reads specific-to-general, but the compiler accepts it first or in the middle — and switch with no matching case and no default simply does nothing, so a dispatch that must handle every value either carries a default or is checked by the compiler through exhaustiveness elsewhere.
The form to reach for whenever three or more branches dispatch on one value: one evaluation, one list, one place to add the next case. The chains it replaces in Conditionals with if and else were readable at two branches; at five they were a pyramid.
switch true: The else-if Chain With Better Optics
Sometimes there is no single value to dispatch on — each branch tests a different condition:
switch {
case n < 0:
return "negative"
case n == 0:
return "zero"
case n < 100:
return "small"
default:
return "large"
}
An omitted tag is switch true, which turns every case into a condition checked in order until one is true. Functionally this is an else-if chain; the difference is optical and practical: the conditions line up in one column at one indentation level, each reads as an independent rule, and there is no else { if { else { stairway to maintain.
Ordering is now load-bearing, and the mental model is first-match-wins: cases run top to bottom, and as soon as one evaluates true the rest are never examined. The example above works only because the ranges are mutually exclusive; overlapping conditions hide behind whichever comes first:
switch {
case n < 100: // catches 0 too — 'case n == 0' below is dead
return "small"
case n == 0:
return "zero" // unreachable for n < 100
}
When a rewrite of one rule breaks an unrelated case, that is the signal the conditions are ordered wrong or should be written as a single explicit predicate per branch. Unlike a value switch, none of these expressions share a type constraint — each is its own boolean — and none need be constant.
The judgment call between forms:
| Situation | Form | Why |
|---|---|---|
| Two branches, symmetric | if / else |
the whole decision visible in one construct |
| 3+ independent conditions | switch true |
flat, ordered, easy to add a rule |
| 3+ branches on one value | tagged switch |
one evaluation, grouped cases, no repeated == |
| Branches test types of one interface | type switch | dispatch on dynamic type (next chapter) |
One performance note for the record: do not "optimize" a value switch into an if-chain by hand. The compiler already treats dense integer case lists as a dispatch structure — a jump table rather than a sequential compare — so the readable form is also the fast form. Reach for switch true as a restructuring of logic, and the tagged switch as your default dispatch; both keep the control flow declarative enough that a reader sees the rules rather than the plumbing.
fallthrough: The Keyword That Skips the Question
Every rule in this topic so far has been about not running adjacent code. fallthrough is the deliberate exception: it transfers control to the next case's body without evaluating that case's expression.
switch level {
case "info":
logInfo(msg)
fallthrough
case "debug":
logDebug(msg) // runs for "info" AND "debug"
case "trace":
logTrace(msg) // only for "trace" — no fallthrough above it
}
Read it as "and also do the next thing": an info entry logs info, then unconditionally executes the debug body even though the tag never matched "debug". The expression is skipped entirely — fallthrough is a jump, not a comparison — which means case bodies in a fallthrough chain must be written to tolerate being entered from above.
Three restrictions keep it contained. It must be the final statement of its case (statements after it are unreachable). It is illegal in the last case — there is no next body to enter, and the compiler says so. And it is forbidden in type switches, where cases name types rather than values and "the next type" has no coherent meaning.
Notice what fallthrough is not: it is not how you group values. For shared bodies, case 1, 2, 3: is the tool — comma-separated expressions entering one body — and most codebases contain zero fallthroughs because grouping plus well-named helpers covers the legitimate cases. When you do see one, it should read as an intentional cascade (the log-level example above), not as a substitute for a missing comma.
The other switch Go ships works on a different axis entirely. Instead of comparing the tag against values, a type switch asks what dynamic type an interface value currently holds:
switch v := value.(type) {
case int:
fmt.Println("int:", v*2) // v is bound to the int in this branch
case string:
fmt.Println("string:", v)
default:
fmt.Printf("other %T\n", v)
}
Each case is a type, v is re-declared per branch with that branch's type, and there is no fallthrough because branches are disjoint by construction. That is dispatch on what a value is rather than what it equals — the type-assertion machinery from Type Assertions and Type Switches in statement form, and the natural successor to this topic once values-and-conditions dispatch is automatic. With branching and dispatch settled, the last piece of control flow is repetition — Go's one loop keyword, in Loops with for.
Quiz
1. A switch has `case 1, 2:` with an empty body followed by `case 3:`. If the tag is 2, what executes?
2. The tag expression has a side effect: `switch counter.next() { case ... }`. How many times does next() run?
3. Inside `case "info":`, a `fallthrough` jumps into `case "debug":`. What must be true?
4. You have five independent boolean conditions forming a deeply nested if/else if chain. What is the idiomatic restructure?
Flashcards
Question
What is Go's default fallthrough behavior, and how do you share one body across several case values?
Click to reveal answer
Answer
There is none: execution enters the single matching case and leaves the switch at its end — empty cases do nothing and adjacent bodies never run implicitly. Group values with a comma list (`case 1, 2, 3:`) for a shared body; reserve the explicit `fallthrough` keyword for intentional cascades into the next body.
Question
How many times is a switch's tag expression evaluated, and what does that imply for side effects?
Click to reveal answer
Answer
Exactly once, before any case is compared — the resulting value is matched down the list in order. A tag with side effects runs them a single time regardless of how many cases exist, which is why `switch x := compute(); x` with an init statement (binding the same value for use in the bodies) is a clean pattern.
Question
What does an omitted tag — `switch {` — mean, and what determines which case runs?
Click to reveal answer
Answer
It means switch true: every case is a standalone boolean condition, evaluated in source order until one is true, then that body runs. First match wins, so ordering encodes priority — overlapping conditions make earlier cases shadow later ones, which is the main bug class of this form.
Question
Where is the fallthrough statement illegal, and why?
Click to reveal answer
Answer
In the last case (there is no following body to enter — the compiler rejects it) and in type switches (cases are types, so 'the next case' has no defined meaning). It must also be the final statement of its own case, since anything after it would be unreachable.
Question
When does a tagged switch beat an if/else chain, and when does it not?
Click to reveal answer
Answer
Three or more branches dispatching on one value: the tag is evaluated once, cases group with commas, there is no repeated ==, and adding a branch is one list entry. For two symmetric branches or conditions on unrelated values, if/else (or switch true for independent conditions) reads more directly — dispatch on one value is switch's home turf.
Revision Notes
Key Takeaways
- 1. A switch evaluates its tag once and compares down the list — one computation, one dispatch, no per-branch re-evaluation
- 2. Go never falls through implicitly; grouping is commas, and an empty case is genuinely empty
- 3. Cases may be arbitrary comparable expressions, not just constants — the tag can be any value of a matching type
- 4. switch true turns an else-if pyramid into a flat ordered rule list, with first-match-wins making order semantic
- 5. fallthrough is a deliberate jump that skips the next case's expression — last-case and type-switch usage are compile errors
Interview Tips
- • "How does Go's switch differ from C's?" — no implicit fallthrough (the classic C bug), comma grouping, cases need not be constants, and any comparable type works; give the empty-case example to show you know grouping is not fallthrough
- • "When would you use switch true instead of if/else?" — three or more independent conditions; mention first-match-wins so ordering becomes part of your answer's correctness story
- • "What does fallthrough actually do?" — jumps to the next case's body without evaluating its expression; add the two legality constraints (not last case, not in type switches)
- • "Explain the type switch form" — `switch v := x.(type)` binds v with each case's type, giving you both dispatch and a typed name per branch; note it previews type assertions' comma-ok design
Cheat Sheet
switch Statements
Forms:
| Form | Tag | Cases are | Use for |
|---|---|---|---|
tagged switch x { |
evaluated once | expressions of x's type | dispatch on one value |
switch { |
omitted (= true) |
independent booleans | ordered multi-condition rules |
switch v := i.(type) { |
omitted | types | dispatch on dynamic type (see type assertions) |
Rules:
- No implicit fallthrough — one body runs, then the switch exits
- Group values with commas:
case 1, 2, 3:— an empty case shares nothing - Case expressions need not be constants; tag evaluated exactly once
defaultoptional, position free (convention: last)- First match wins — order overlapping cases by priority
fallthroughruns the next body without testing it; illegal in the last case and in type switches
Code template:
switch code := resp.StatusCode; {
case code >= 200 && code < 300:
return nil
case code == 404:
return ErrNotFound
case code >= 500:
return fmt.Errorf("upstream: %d", code)
default:
return fmt.Errorf("unexpected: %d", code)
}
Decision rule: 2 symmetric branches → if/else · 3+ unrelated conditions → switch true · 3+ values of one expression → tagged switch · types → type switch.
Continue Learning
Before You Start
- No prerequisites listed.