The switch Statement You Always Wanted

C#’s switch statement has evolved a lot over the years. Pattern matching, switch expressions, when guards—it’s become genuinely powerful. But Go’s switch has some tricks that’ll make you wish C# worked this way.

Let’s look at what Go’s switch can do, including the bits that’ll trip you up.

Basic Switch: No Fallthrough by Default

First surprise: Go switches don’t fall through by default.

// C# - falls through if you forget break (historically)
switch (day)
{
    case "Monday":
        Console.WriteLine("Start of week");
        break;  // required!
    case "Friday":
        Console.WriteLine("End of week");
        break;
}
// Go - no break needed, no fallthrough
switch day {
case "Monday":
    fmt.Println("Start of week")
    // automatically breaks here
case "Friday":
    fmt.Println("End of week")
}

This is a genuine improvement. The C# pattern of requiring break after every case was a source of bugs for decades. Go’s default—exit after each case—matches what you almost always want.

Multiple Values Per Case

Want the same code for multiple cases? In C#:

switch (day)
{
    case "Saturday":
    case "Sunday":
        Console.WriteLine("Weekend");
        break;
}

In Go:

switch day {
case "Saturday", "Sunday":
    fmt.Println("Weekend")
}

Cleaner. Multiple values in one case clause, comma-separated.

No Expression Required

Here’s something C# can’t do. Go’s switch doesn’t need an expression:

switch {
case hour < 12:
    fmt.Println("Good morning")
case hour < 17:
    fmt.Println("Good afternoon")
default:
    fmt.Println("Good evening")
}

This is equivalent to an if-else chain but often more readable:

// Equivalent, but more verbose
if hour < 12 {
    fmt.Println("Good morning")
} else if hour < 17 {
    fmt.Println("Good afternoon")
} else {
    fmt.Println("Good evening")
}

I use expressionless switch constantly. It’s particularly good for range checks and complex conditions.

Fallthrough (When You Actually Want It)

Sometimes you genuinely need fallthrough. Go has a fallthrough keyword:

switch day {
case "Thursday":
    fmt.Println("Almost Friday")
    fallthrough
case "Friday":
    fmt.Println("Weekend soon!")
}

// Thursday prints both lines
// Friday prints just "Weekend soon!"

Important: fallthrough must be the last statement in a case, and it falls into the next case unconditionally—it doesn’t re-evaluate the case expression.

// This is usually wrong
switch x {
case 1:
    fallthrough  // falls into case 2 even if x != 2
case 2:
    doSomething()
}

In practice, I rarely use fallthrough. Multiple values per case covers most needs.

Type Switches

This is where Go’s switch shines. You can switch on type:

func describe(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Printf("Integer: %d\n", v)
    case string:
        fmt.Printf("String: %s\n", v)
    case bool:
        fmt.Printf("Boolean: %t\n", v)
    case []int:
        fmt.Printf("Slice of ints: %v\n", v)
    default:
        fmt.Printf("Unknown type: %T\n", v)
    }
}

The v := i.(type) syntax extracts both the type (for the case matching) and the value (as the correctly typed v).

C# has pattern matching now which does something similar:

static void Describe(object o)
{
    switch (o)
    {
        case int i:
            Console.WriteLine($"Integer: {i}");
            break;
        case string s:
            Console.WriteLine($"String: {s}");
            break;
        // etc.
    }
}

Go got there first, and the syntax is arguable cleaner.

Multiple Types Per Case

switch v := i.(type) {
case int, int32, int64:
    fmt.Println("Some kind of integer")
    // Note: v is interface{} here, not a specific int type
case string, []byte:
    fmt.Println("String-like")
default:
    fmt.Printf("Type: %T\n", v)
}

When you match multiple types, v has type interface{} because the compiler can’t know which type matched.

Initialisation Statements

Like if, you can have an initialisation statement:

switch os := runtime.GOOS; os {
case "darwin":
    fmt.Println("macOS")
case "linux":
    fmt.Println("Linux")
default:
    fmt.Printf("Other: %s\n", os)
}
// os is not visible here

Keeps the variable scoped to where it’s used.

Comparing to C#’s Switch Expressions

C# 8 added switch expressions, which are excellent:

var message = day switch
{
    "Monday" => "Start of week",
    "Friday" => "End of week",
    "Saturday" or "Sunday" => "Weekend",
    _ => "Midweek"
};

Go doesn’t have switch expressions—switch is always a statement, not an expression. You can’t do:

// This doesn't work in Go
message := switch day {
case "Monday":
    "Start of week"
// ...
}

You have to use a variable:

var message string
switch day {
case "Monday":
    message = "Start of week"
case "Friday":
    message = "End of week"
case "Saturday", "Sunday":
    message = "Weekend"
default:
    message = "Midweek"
}

This is one area where C# is genuinely more elegant.

When Guards (Sort Of)

C# has when guards in pattern matching:

switch (response)
{
    case HttpResponse { StatusCode: var code } when code >= 400:
        HandleError(code);
        break;
}

Go doesn’t have when, but expressionless switch achieves similar things:

switch {
case response.StatusCode >= 500:
    handleServerError(response)
case response.StatusCode >= 400:
    handleClientError(response)
case response.StatusCode >= 200:
    handleSuccess(response)
}

Not quite as powerful as full pattern matching, but covers most cases.

The Default Case

Go’s default case can appear anywhere (not just at the end):

switch day {
default:
    fmt.Println("Weekday")
case "Saturday", "Sunday":
    fmt.Println("Weekend")
}

Convention says put it last, but Go doesn’t enforce it.

Switching on Errors

A common pattern for error handling:

err := doSomething()
switch {
case err == nil:
    // success
case errors.Is(err, ErrNotFound):
    // handle not found
case errors.Is(err, ErrPermissionDenied):
    // handle permission error
default:
    // unknown error
    return err
}

Sometimes cleaner than a chain of if-else-if.

The Comparison

FeatureC#Go
Break requiredNo (modern)No
FallthroughBy omitting break (legacy)fallthrough keyword
Multiple valuescase a, b: (older) or case a or b:case a, b:
Expression switchYes (switch expression)No (statement only)
Type switchingPattern matchingswitch v := x.(type)
No expressionNoYes (switch { case cond: })
When guardsYesNo (use expressionless switch)
Init statementNoYes

Practical Patterns

State Machine

type State int

const (
    StateIdle State = iota
    StateRunning
    StatePaused
    StateStopped
)

func (s *Machine) handleEvent(event Event) {
    switch s.state {
    case StateIdle:
        switch event {
        case EventStart:
            s.state = StateRunning
        default:
            // ignore
        }
    case StateRunning:
        switch event {
        case EventPause:
            s.state = StatePaused
        case EventStop:
            s.state = StateStopped
        }
    // etc.
    }
}

Command Dispatch

func handleCommand(cmd string, args []string) error {
    switch cmd {
    case "list":
        return listItems()
    case "add":
        if len(args) < 1 {
            return errors.New("add requires an item name")
        }
        return addItem(args[0])
    case "remove":
        if len(args) < 1 {
            return errors.New("remove requires an item name")
        }
        return removeItem(args[0])
    default:
        return fmt.Errorf("unknown command: %s", cmd)
    }
}

Parsing JSON with Type Switch

func processValue(v interface{}) {
    switch val := v.(type) {
    case map[string]interface{}:
        for k, v := range val {
            fmt.Printf("Object key: %s\n", k)
            processValue(v)  // recurse
        }
    case []interface{}:
        for i, item := range val {
            fmt.Printf("Array index: %d\n", i)
            processValue(item)  // recurse
        }
    case string:
        fmt.Printf("String: %s\n", val)
    case float64:  // JSON numbers are float64
        fmt.Printf("Number: %f\n", val)
    case bool:
        fmt.Printf("Boolean: %t\n", val)
    case nil:
        fmt.Println("Null")
    }
}

The Honest Take

Go’s switch is one of the language’s better features:

Things I like:

  • No fallthrough by default (prevents bugs)
  • Expressionless switch for complex conditions
  • Type switches for working with interfaces
  • Multiple values per case
  • Init statements for scoping

Things C# does better:

  • Switch expressions (returning values directly)
  • Pattern matching with when guards
  • Recursive patterns (case Person { Age: > 18 })

The verdict: Go’s switch is simpler and covers the common cases elegantly. C#’s switch has more features for complex matching. Use what each language gives you.


That wraps up Phase 3 on control flow and error handling. Next we’ll move into concurrency—goroutines, channels, and why Go’s approach to parallelism is fundamentally different from async/await.