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
ifinitialization 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 toif/else ifchains — each case is a full condition - Multiple values per case (
case "a", "b":) eliminates most need forfallthrough - Type switches dispatch on the concrete type stored in an interface — but use
errors.Asfor error types &&and||short-circuit — put cheap or nil-guarding checks on the left side
Comments