Conditional Branching #

Conditionals in Go look familiar — there’s if and switch like in almost every other language. But Go makes several design decisions that change how you write branching: if can have an initializer statement that limits a variable’s scope, switch has no automatic fallthrough so it doesn’t need break in every case, and the Go community has a strong preference for early returns over deeply nested ifs. These decisions aren’t coincidental — they all push toward code that’s easier to read and maintain.

The if Statement #

The if syntax in Go doesn’t require parentheses around the condition — this differs from C, Java, and JavaScript:

age := 20

// ✓ Idiomatic Go — no parentheses around the condition
if age >= 18 {
    fmt.Println("adult")
}

// ✗ Not idiomatic — parentheses are unnecessary (but not an error)
if (age >= 18) {
    fmt.Println("adult")
}

The curly braces { are mandatory, even for one-line blocks. There’s no one-liner if like in C:

// ✗ Compile error in Go — curly braces are required
if age >= 18
    fmt.Println("adult")

// ✓ Curly braces are always required
if age >= 18 {
    fmt.Println("adult")
}

if with an Initializer Statement #

This is a Go feature that doesn’t exist in many other languages — you can declare a variable right inside the if condition, and that variable only lives within the if block’s scope:

// Without an initializer — the err variable leaks into the outer scope
err := doSomething()
if err != nil {
    return err
}
// err is still accessible here (not always desirable)

// With an initializer — err is scoped to the if block only
if err := doSomething(); err != nil {
    return err
}
// err is not accessible here — compiler error if you try

This pattern is very idiomatic in Go and used everywhere for error handling:

func loadUserProfile(id int) (*Profile, error) {
    // Each variable is scoped to its own if block
    if id <= 0 {
        return nil, fmt.Errorf("invalid id: %d", id)
    }

    if user, err := db.FindUser(id); err != nil {
        return nil, fmt.Errorf("loadUserProfile: %w", err)
    } else if !user.Active {
        return nil, errors.New("account is not active")
    } else {
        return buildProfile(user), nil
    }
}

The initializer is also very useful with type assertions:

var val interface{} = "Hello, Go!"

// Type assertion with an initializer — s only exists inside the if block
if s, ok := val.(string); ok {
    fmt.Printf("a string of length %d\n", len(s))
} else {
    fmt.Printf("not a string, but a %T\n", val)
}

// s is not accessible here

if-else and if-else if #

score := 78

if score >= 90 {
    fmt.Println("A")
} else if score >= 80 {
    fmt.Println("B")
} else if score >= 70 {
    fmt.Println("C")
} else if score >= 60 {
    fmt.Println("D")
} else {
    fmt.Println("F")
}

Important rule: else and else if must be on the same line as the closing } of the previous block. This relates to the automatic semicolon insertion rule covered in the Core Syntax article:

// ✓ Correct
if condition {
    // ...
} else {
    // ...
}

// ✗ Compile error — else on a new line
if condition {
    // ...
}
else {     // ← syntax error: unexpected else
    // ...
}

Early Return — The Anti-Pyramid of Doom #

This isn’t just about style — it’s a pattern strongly encouraged in the Go community. Instead of stacking success conditions into nested ifs that drift further right, handle error or edge cases first and return immediately, letting the happy path flow straight down without excessive indentation. The difference in logic flow between nested if code and early return (guard clauses) code can be seen in the diagram below:

flowchart TD
    subgraph Nested["Pyramid of Doom (Nested If)"]
        Check1{"Order != nil?"} -->|"Yes"| Check2{"User != nil?"}
        Check1 -->|"No"| Err1["Return Error"]
        Check2 -->|"Yes"| Check3{"Enough balance?"}
        Check2 -->|"No"| Err2["Return Error"]
        Check3 -->|"Yes"| HappyPathNested["Happy Path (Deepest Nest)"]
        Check3 -->|"No"| Err3["Return Error"]
    end

    subgraph Guard["Idiomatic Go (Guard Clauses)"]
        G1{"Order == nil?"} -->|"Yes"| GE1["Return Error"]
        G1 -->|"No"| G2{"User == nil?"} -->|"Yes"| GE2["Return Error"]
        G2 -->|"No"| G3{"Balance too low?"} -->|"Yes"| GE3["Return Error"]
        G3 -->|"No"| HappyPathGuard["Happy Path (Straight & Linear)"]
    end
// ANTI-PATTERN: pyramid of doom
// The happy path is buried in the deepest indentation
func processPayment(order *Order, user *User) error {
    if order != nil {
        if user != nil {
            if user.Balance >= order.Total {
                if order.Items > 0 {
                    if !order.IsDuplicate() {
                        err := chargeUser(user, order.Total)
                        if err == nil {
                            return saveOrder(order)
                        } else {
                            return fmt.Errorf("charge failed: %w", err)
                        }
                    } else {
                        return errors.New("duplicate order")
                    }
                } else {
                    return errors.New("empty order")
                }
            } else {
                return errors.New("insufficient balance")
            }
        } else {
            return errors.New("user not found")
        }
    } else {
        return errors.New("order must not be nil")
    }
}

// CORRECT: early return — clean, linear, easy to read
func processPayment(order *Order, user *User) error {
    // Guard clauses — handle all error conditions up front
    if order == nil {
        return errors.New("order must not be nil")
    }
    if user == nil {
        return errors.New("user not found")
    }
    if order.Items == 0 {
        return errors.New("empty order")
    }
    if order.IsDuplicate() {
        return errors.New("duplicate order")
    }
    if user.Balance < order.Total {
        return errors.New("insufficient balance")
    }

    // Happy path — clean at the bottom
    if err := chargeUser(user, order.Total); err != nil {
        return fmt.Errorf("charge failed: %w", err)
    }
    return saveOrder(order)
}

Both functions above do exactly the same thing — but the second one is far easier to read, test, and modify. It even has fewer lines.

Early return rule of thumb: if you find yourself writing } else { after handling an error condition, that’s a sign you should be using an early return. Error conditions are returned early, so no else is needed.

The switch Statement #

switch in Go is cleaner and more expressive than switch/case in C or Java. The most important difference: there’s no automatic fallthrough. In C, you must write break in every case — forgetting one break is a classic bug. In Go, every case automatically stops at the end of its block:

day := "Monday"

switch day {
case "Monday":
    fmt.Println("Start of the work week")
    // no break needed — stops automatically here
case "Wednesday":
    fmt.Println("Middle of the week")
case "Friday":
    fmt.Println("End of the work week")
default:
    fmt.Println("Another day")
}

Multiple Values Per Case #

A single case can handle several values at once, separated by commas:

day := "Saturday"

switch day {
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday":
    fmt.Println("Workday")
case "Saturday", "Sunday":
    fmt.Println("Weekend")
default:
    fmt.Println("Not a valid day name")
}

switch with an Initializer #

Just like if, switch can also have an initializer statement:

switch os := runtime.GOOS; os {
case "darwin":
    fmt.Println("macOS")
case "linux":
    fmt.Println("Linux")
case "windows":
    fmt.Println("Windows")
default:
    fmt.Printf("other platform: %s\n", os)
}

// os is not accessible here

switch Without an Expression #

A switch without an expression acts like a series of if-else if — Go evaluates each case from top to bottom and executes the first case whose condition is true. This is much cleaner than a long if-else if chain:

temperature := 35

switch {
case temperature < 0:
    fmt.Println("Freezing")
case temperature < 10:
    fmt.Println("Very cold")
case temperature < 20:
    fmt.Println("Cold")
case temperature < 30:
    fmt.Println("Mild")
case temperature < 38:
    fmt.Println("Hot")
default:
    fmt.Println("Very hot")
}

// Case conditions can be complex
score := 85
switch {
case score >= 90 && score <= 100:
    fmt.Println("A — Excellent")
case score >= 80:
    fmt.Println("B — Good")
case score >= 70:
    fmt.Println("C — Fair")
case score >= 60:
    fmt.Println("D — Poor")
default:
    fmt.Println("F — Failing")
}

fallthrough — Explicit and Rarely Used #

If you really need to fall through to the next case, Go provides the fallthrough keyword, which must be written explicitly:

value := 2

switch value {
case 1:
    fmt.Println("one")
    fallthrough  // continue to case 2
case 2:
    fmt.Println("two")
    fallthrough  // continue to case 3
case 3:
    fmt.Println("three")
    // no fallthrough — stops here
case 4:
    fmt.Println("four")  // not executed
}
// Output:
// two
// three

A few important things about fallthrough:

// 1. fallthrough does NOT check the next case's condition
n := 5
switch {
case n > 3:
    fmt.Println("greater than 3")
    fallthrough
case n > 10:
    // This still executes even though n=5 is not > 10!
    // fallthrough doesn't check the condition — it goes straight to the body
    fmt.Println("this block always executes after fallthrough")
}

// 2. fallthrough must be the last statement in a case
switch x {
case 1:
    fmt.Println("one")
    fallthrough
    fmt.Println("this is an error")  // ← compile error: fallthrough must be the last statement
case 2:
    fmt.Println("two")
}

// 3. fallthrough can't be used in the last case (default)
switch x {
case 1:
    fmt.Println("one")
default:
    fallthrough  // ← compile error: can't fallthrough from default
}
Use fallthrough very carefully. Because it skips checking the next case’s condition, its behavior is often surprising. In almost all cases, the cleaner alternative is combining values in one case (case 1, 2, 3:) or calling the same helper function from several cases.

Type Switches #

A type switch is a special form of switch for checking the dynamic type of an interface value. It’s Go’s idiomatic way of handling values with several possible types:

func describe(i interface{}) string {
    switch v := i.(type) {
    case nil:
        return "nil value"
    case int:
        return fmt.Sprintf("integer: %d", v)
    case int64:
        return fmt.Sprintf("integer64: %d", v)
    case float64:
        return fmt.Sprintf("float: %.2f", v)
    case string:
        return fmt.Sprintf("string: %q (len=%d)", v, len(v))
    case bool:
        if v {
            return "boolean: true"
        }
        return "boolean: false"
    case []int:
        return fmt.Sprintf("slice of int: %v (len=%d)", v, len(v))
    case error:
        return "error: " + v.Error()
    default:
        return fmt.Sprintf("unknown type: %T", v)
    }
}

func main() {
    fmt.Println(describe(42))
    fmt.Println(describe("Hello"))
    fmt.Println(describe(3.14))
    fmt.Println(describe(nil))
    fmt.Println(describe([]int{1, 2, 3}))
    fmt.Println(describe(errors.New("oops")))
}

Type Switches with Specific Interfaces #

Type switches aren’t only for interface{} — they work with any interface:

type Stringer interface {
    String() string
}

type JSONMarshaler interface {
    MarshalJSON() ([]byte, error)
}

func formatValue(v interface{}) string {
    switch val := v.(type) {
    case Stringer:
        // Any type that has a String() method
        return val.String()
    case JSONMarshaler:
        // A type that can be marshaled to JSON
        b, err := val.MarshalJSON()
        if err != nil {
            return fmt.Sprintf("error: %v", err)
        }
        return string(b)
    case fmt.Stringer:
        // The built-in interface from the fmt package
        return val.String()
    default:
        return fmt.Sprintf("%v", val)
    }
}

Labeled break in switch #

When a switch sits inside a loop, you might need to exit the loop (not just the switch) using a labeled break:

items := []string{"apple", "stop", "mango", "orange"}

loop:
    for i, item := range items {
        switch item {
        case "stop":
            fmt.Printf("Stopped at index %d\n", i)
            break loop  // exits the FOR loop, not the switch
        default:
            fmt.Println("Process:", item)
        }
    }
// Output:
// Process: apple
// Stopped at index 1

Without the loop: label, a break inside switch only exits the switch — not the enclosing loop. The loop would continue to the next item.


Idiomatic Patterns #

Guard Clauses — Validation at the Start of Functions #

The guard clause pattern applies early return to validate all function preconditions up front, before the main logic:

func transferFunds(from, to *Account, amount float64) error {
    // Guard clauses — all validation up top
    if from == nil {
        return errors.New("sender account must not be nil")
    }
    if to == nil {
        return errors.New("receiver account must not be nil")
    }
    if from.ID == to.ID {
        return errors.New("can't transfer to the same account")
    }
    if amount <= 0 {
        return fmt.Errorf("transfer amount must be positive, got %.2f", amount)
    }
    if amount > from.Balance {
        return fmt.Errorf("insufficient balance: have %.2f, need %.2f",
            from.Balance, amount)
    }
    if !from.Active || !to.Active {
        return errors.New("both accounts must be active to transfer")
    }

    // Main logic — clean, without validation distractions
    from.Balance -= amount
    to.Balance += amount
    return saveTransaction(from, to, amount)
}

State Machines with switch #

switch is perfect for implementing state machines — a very common pattern in production code:

type OrderStatus int

const (
    StatusDraft OrderStatus = iota
    StatusSubmitted
    StatusPaid
    StatusShipped
    StatusDelivered
    StatusCancelled
)

type Order struct {
    ID     int
    Status OrderStatus
}

func (o *Order) Transition(newStatus OrderStatus) error {
    switch o.Status {
    case StatusDraft:
        if newStatus != StatusSubmitted && newStatus != StatusCancelled {
            return fmt.Errorf("from Draft you can only go to Submitted or Cancelled")
        }
    case StatusSubmitted:
        if newStatus != StatusPaid && newStatus != StatusCancelled {
            return fmt.Errorf("from Submitted you can only go to Paid or Cancelled")
        }
    case StatusPaid:
        if newStatus != StatusShipped {
            return fmt.Errorf("from Paid you can only go to Shipped")
        }
    case StatusShipped:
        if newStatus != StatusDelivered {
            return fmt.Errorf("from Shipped you can only go to Delivered")
        }
    case StatusDelivered, StatusCancelled:
        return fmt.Errorf("final statuses can't be changed anymore")
    }

    o.Status = newStatus
    return nil
}

Lookup Tables as a switch Alternative #

For simple value mappings, a map is often cleaner than switch:

// switch for a simple mapping — OK but verbose
func dayNameSwitch(n int) string {
    switch n {
    case 0: return "Sunday"
    case 1: return "Monday"
    case 2: return "Tuesday"
    case 3: return "Wednesday"
    case 4: return "Thursday"
    case 5: return "Friday"
    case 6: return "Saturday"
    default: return "Invalid"
    }
}

// A map as a lookup table — cleaner for static mappings
var dayNames = map[int]string{
    0: "Sunday", 1: "Monday", 2: "Tuesday",
    3: "Wednesday", 4: "Thursday", 5: "Friday", 6: "Saturday",
}

func dayNameMap(n int) string {
    if name, ok := dayNames[n]; ok {
        return name
    }
    return "Invalid"
}

Use switch when there’s different logic in each case. Use a map/slice as a lookup table when you’re just mapping values to other values.


Complete Example Program #

The following program simulates an e-commerce order validation and processing system using various conditional branching patterns:

package main

import (
    "errors"
    "fmt"
    "strings"
    "time"
)

type PaymentMethod string

const (
    PaymentCash        PaymentMethod = "cash"
    PaymentCreditCard  PaymentMethod = "credit_card"
    PaymentTransfer    PaymentMethod = "transfer"
    PaymentEWallet     PaymentMethod = "ewallet"
)

type Item struct {
    Name     string
    Price    float64
    Qty      int
    Category string
}

type Order struct {
    ID          string
    Items       []Item
    Payment     PaymentMethod
    VoucherCode string
    CreatedAt   time.Time
}

// Calculate the discount based on the payment method and category
func calculateDiscount(item Item, payment PaymentMethod) float64 {
    baseDiscount := 0.0

    // Discount by category
    switch strings.ToLower(item.Category) {
    case "electronics":
        baseDiscount = 0.05  // 5%
    case "fashion":
        baseDiscount = 0.10  // 10%
    case "food":
        baseDiscount = 0.0   // no discount
    default:
        baseDiscount = 0.02  // 2% for other categories
    }

    // Additional discount based on the payment method
    switch payment {
    case PaymentCreditCard:
        baseDiscount += 0.03  // add 3%
    case PaymentEWallet:
        baseDiscount += 0.05  // add 5%
    case PaymentTransfer:
        baseDiscount += 0.02  // add 2%
    case PaymentCash:
        // no additional discount
    }

    return baseDiscount
}

// Order validation — using the guard clause pattern
func validateOrder(order Order) error {
    if order.ID == "" {
        return errors.New("order ID must not be empty")
    }
    if len(order.Items) == 0 {
        return errors.New("order must have at least 1 item")
    }

    // Validate each item
    for i, item := range order.Items {
        if item.Name == "" {
            return fmt.Errorf("item %d: name must not be empty", i+1)
        }
        if item.Price <= 0 {
            return fmt.Errorf("item %q: price must be positive", item.Name)
        }
        if item.Qty <= 0 {
            return fmt.Errorf("item %q: quantity must be positive", item.Name)
        }
    }

    // Validate the payment method
    switch order.Payment {
    case PaymentCash, PaymentCreditCard, PaymentTransfer, PaymentEWallet:
        // valid
    default:
        return fmt.Errorf("unknown payment method %q", order.Payment)
    }

    return nil
}

// Calculate the order total with discounts
func calculateTotal(order Order) (subtotal, totalDiscount, total float64) {
    for _, item := range order.Items {
        itemTotal := item.Price * float64(item.Qty)
        discountRate := calculateDiscount(item, order.Payment)
        itemDiscount := itemTotal * discountRate

        subtotal += itemTotal
        totalDiscount += itemDiscount
    }

    // Additional discount from the voucher
    if order.VoucherCode != "" {
        switch order.VoucherCode {
        case "GOFIRST10":
            totalDiscount += subtotal * 0.10
        case "WEEKEND15":
            // Only valid on weekends
            weekday := order.CreatedAt.Weekday()
            if weekday == time.Saturday || weekday == time.Sunday {
                totalDiscount += subtotal * 0.15
            }
        case "FLAT50K":
            if subtotal >= 500000 {
                totalDiscount += 50000
            }
        }
    }

    // Make sure the discount doesn't exceed the subtotal
    if totalDiscount > subtotal {
        totalDiscount = subtotal
    }

    total = subtotal - totalDiscount
    return
}

// Determine the shipping method based on the total
func shippingRecommendation(total float64) string {
    switch {
    case total >= 1_000_000:
        return "Free Shipping — Express Courier"
    case total >= 500_000:
        return "Free Shipping — Regular Courier"
    case total >= 200_000:
        return "50% Shipping Discount"
    default:
        return "Standard Shipping"
    }
}

func main() {
    order := Order{
        ID: "ORD-2024-001",
        Items: []Item{
            {Name: "UltraBook Laptop", Price: 12_000_000, Qty: 1, Category: "Electronics"},
            {Name: "Plain T-Shirt",    Price: 85_000,     Qty: 3, Category: "Fashion"},
            {Name: "Arabica Coffee",   Price: 120_000,    Qty: 2, Category: "Food"},
        },
        Payment:     PaymentEWallet,
        VoucherCode: "GOFIRST10",
        CreatedAt:   time.Now(),
    }

    // Validate the order
    if err := validateOrder(order); err != nil {
        fmt.Println("Invalid order:", err)
        return
    }

    subtotal, totalDiscount, total := calculateTotal(order)
    shipping := shippingRecommendation(total)

    fmt.Printf("=== Order Summary %s ===\n\n", order.ID)

    fmt.Println("Items:")
    for _, item := range order.Items {
        discountRate := calculateDiscount(item, order.Payment)
        itemTotal := item.Price * float64(item.Qty)
        fmt.Printf("  %-20s %dx Rp%.0f = Rp%.0f (%.0f%% discount)\n",
            item.Name, item.Qty, item.Price, itemTotal, discountRate*100)
    }

    fmt.Printf("\nSubtotal        : Rp%.0f\n", subtotal)
    fmt.Printf("Total Discount  : Rp%.0f\n", totalDiscount)
    fmt.Printf("Voucher         : %s\n", order.VoucherCode)
    fmt.Printf("Payment Method  : %s\n", order.Payment)
    fmt.Printf("\nTotal Payment   : Rp%.0f\n", total)
    fmt.Printf("Shipping        : %s\n", shipping)
}

Summary #

  • No parentheses needed around the if condition — if x > 0 {} not if (x > 0) {}, but the curly braces {} are always mandatory.
  • if with an initializer (if err := f(); err != nil) limits the variable’s scope to the if block — cleaner than a separate declaration.
  • Early return (guard clauses) instead of nested ifs — handle errors and edge cases up top, let the happy path flow cleanly below.
  • else and else if must be on the same line as the previous block’s closing }.
  • switch doesn’t need break — no automatic fallthrough like in C/Java.
  • Multi-value cases (case "Saturday", "Sunday":) handle several values in one case.
  • A switch without an expression acts as if-else if — cases are evaluated from the top, executing the first true one.
  • fallthrough is explicit and doesn’t check the next case’s condition — use it with great care.
  • Type switches (switch v := i.(type)) elegantly handle various interface types.
  • Labeled break exits the loop wrapping a switch, not just the switch itself.

← Previous: Operators   Next: Loops →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact