Skip to main content

Conditional Statements in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 5 min read

Go’s conditional statements are deliberately simple: if/else for binary choices, switch for multiple cases. The language intentionally lacks a ternary operator — you write the explicit if/else instead. This keeps every branch clearly labeled and makes Go code highly scannable.

The distinctive features worth learning are the initialization clause in if statements and the expressionless switch (which acts like a cleaner if/else if chain). These appear throughout Go codebases and are worth recognizing on first encounter.

if and else

The basic form requires braces — no single-line conditionals:

age := 22

if age >= 18 {
    fmt.Println("adult")
} else {
    fmt.Println("minor")
}

Initialization Clause

if accepts an optional initialization statement before the condition, separated by ;. The initialized variable is scoped to the if/else block:

// Common pattern: initialize and immediately check
if err := doSomething(); err != nil {
    log.Printf("failed: %v", err)
    return
}
// err is not accessible here — intentionally scoped to the if block

// Or for checking map presence / type assertions
if val, ok := m["key"]; ok {
    process(val)
}

This pattern is idiomatic for error checks and existence tests — the short-lived variable doesn’t escape into the surrounding scope.

Guard Clauses: Prefer Early Returns Over Nesting

Deep nesting is harder to read than flat code. Go style strongly prefers early returns for preconditions (“guard clauses”) over nesting the happy path:

// ❌ Nested — the happy path is buried
func process(req Request) error {
    if req.ID != "" {
        if req.Amount > 0 {
            if req.Currency != "" {
                return doProcess(req)
            } else {
                return fmt.Errorf("currency required")
            }
        } else {
            return fmt.Errorf("amount must be positive")
        }
    } else {
        return fmt.Errorf("ID required")
    }
}

// ✅ Guard clauses — errors first, happy path at the end
func process(req Request) error {
    if req.ID == "" {
        return fmt.Errorf("ID required")
    }
    if req.Amount <= 0 {
        return fmt.Errorf("amount must be positive")
    }
    if req.Currency == "" {
        return fmt.Errorf("currency required")
    }
    return doProcess(req)
}

The guard clause pattern matches how Go handles errors throughout the standard library — check the error condition, return early, continue with the valid case.

switch Statements

switch selects between cases without falling through (unlike C). You don’t need break — it’s implicit:

status := "active"

switch status {
case "active":
    fmt.Println("running")
case "paused", "idle":  // multiple values in one case
    fmt.Println("not running")
case "error":
    fmt.Println("failed")
default:
    fmt.Println("unknown status:", status)
}

Expressionless switch

When switch has no expression, it acts like a cleaner if/else if chain — each case is a boolean condition:

score := 85

switch {
case score >= 90:
    grade = "A"
case score >= 80:
    grade = "B"
case score >= 70:
    grade = "C"
default:
    grade = "F"
}

This is more readable than the equivalent if/else if chain, especially when the conditions share a theme. It also allows non-comparable types in conditions — you can mix different variables in different cases.

Initialization Clause in switch

Like if, switch accepts an initialization statement:

switch env := os.Getenv("APP_ENV"); env {
case "production":
    setupProductionConfig()
case "staging":
    setupStagingConfig()
default:
    setupDevConfig()
}
// env is scoped to the switch block

fallthrough

Go switch cases don’t fall through by default. Use fallthrough explicitly when you need the next case to execute regardless of its condition:

switch x {
case 1:
    fmt.Println("one")
    fallthrough  // execute case 2 body unconditionally
case 2:
    fmt.Println("two or one")
case 3:
    fmt.Println("three")
}

fallthrough is rare in idiomatic Go — the multiple-value case form (case 1, 2:) covers most uses. See it mostly in code that mimics C switch behavior.

Type Switch

A type switch matches the concrete type held in an interface variable. This is the idiomatic way to handle values of different types from a common interface:

func describe(v any) string {
    switch val := v.(type) {
    case int:
        return fmt.Sprintf("int: %d", val)
    case string:
        return fmt.Sprintf("string: %q (len %d)", val, len(val))
    case bool:
        return fmt.Sprintf("bool: %v", val)
    case []int:
        return fmt.Sprintf("[]int with %d elements", len(val))
    case nil:
        return "nil"
    default:
        return fmt.Sprintf("unknown type: %T", v)
    }
}

Inside each case, val has the matched concrete type — val is int in case int:, string in case string:, etc. The default case handles anything else.

For error type extraction, prefer errors.As over a type switch — it traverses wrapped error chains:

// ✅ errors.As — works through error wrapping
var ne *NotFoundError
if errors.As(err, &ne) {
    http.Error(w, ne.Resource+" not found", 404)
}

// ❌ Type switch — doesn't unwrap
switch e := err.(type) {
case *NotFoundError:
    http.Error(w, e.Resource+" not found", 404)
// fails if err is wrapped: fmt.Errorf("query: %w", ne)
}

Logical Operators and Short-Circuit Evaluation

&& and || short-circuit: && stops at the first false, || stops at the first true:

// Safe: db is only checked if p != nil
if p != nil && p.Age >= 18 {
    // ...
}

// getUser() only called if cache miss
if val, ok := cache.Get(id); ok || getUser(id) != nil {
    // ...
}

Short-circuit evaluation means expensive or potentially-panicking operations can be guarded by a cheaper check on the left side.

Summary

  • if initialization clause (if x := f(); x != nil) scopes the variable to the block — use it for error checks and map lookups
  • Guard clauses (early returns) keep the happy path at the top level and reduce nesting — idiomatic Go style
  • Expressionless switch {} is a readable alternative to if/else if chains — each case is a full condition
  • Multiple values per case (case "a", "b":) eliminates most need for fallthrough
  • Type switches dispatch on the concrete type stored in an interface — but use errors.As for error types
  • && and || short-circuit — put cheap or nil-guarding checks on the left side

Resources

Comments

👍 Was this article helpful?