Go Control Flow
Source: Effective Go. Go's control structures are related to C but differ in important ways. Understanding these differences is essential for writing idiomatic Go code.
Go has no do or while loop—only a generalized for. There are no parentheses around conditions, and bodies must always be brace-delimited.
If Statements Basic Form
Go's if requires braces and has no parentheses around the condition:
if x > 0 { return y }
If with Initialization
if and switch accept an optional initialization statement. This is common for scoping variables to the conditional block:
// Good: err scoped to if block if err := file.Chmod(0664); err != nil { log.Print(err) return err }
Omit Else for Early Returns
When an if body ends with break, continue, goto, or return, omit the unnecessary else. This keeps the success path unindented:
// Good: no else, success path at left margin f, err := os.Open(name) if err != nil { return err } codeUsing(f)
// Bad: else clause buries normal flow f, err := os.Open(name) if err != nil { return err } else { codeUsing(f) // unnecessarily indented }
Guard Clauses for Error Handling
Code reads well when the success path flows down the page, eliminating errors as they arise:
// Good: guard clauses eliminate errors early f, err := os.Open(name) if err != nil { return err } d, err := f.Stat() if err != nil { f.Close() return err } codeUsing(f, d)
Redeclaration and Reassignment
The := short declaration allows redeclaring variables in the same scope under specific conditions:
f, err := os.Open(name) // declares f and err // ... d, err := f.Stat() // declares d, reassigns err (not a new err)
A variable v may appear in a := declaration even if already declared, provided:
The declaration is in the same scope as the existing v The value is assignable to v At least one other variable is newly created by the declaration
This pragmatic rule makes it easy to reuse a single err variable through a chain of operations.
// Good: err reused across multiple calls data, err := fetchData() if err != nil { return err } result, err := processData(data) // err reassigned, result declared if err != nil { return err }
Warning: If v is declared in an outer scope, := creates a new variable that shadows it:
// Bad: accidental shadowing var err error if condition { x, err := someFunc() // this err shadows the outer err! // outer err remains nil }
For Loops
Go unifies for and while into a single construct with three forms:
// C-style for (only form with semicolons) for init; condition; post { }
// While-style (condition only) for condition { }
// Infinite loop for { }
Range Clause
Use range to iterate over arrays, slices, strings, maps, and channels:
// Iterate with key and value for key, value := range oldMap { newMap[key] = value }
// Key/index only (drop the second variable) for key := range m { if key.expired() { delete(m, key) } }
// Value only (use blank identifier for index) for _, value := range array { sum += value }
Range Over Strings
For strings, range iterates over UTF-8 encoded runes (not bytes), handling multi-byte characters automatically.
Parallel Assignment in For
Go has no comma operator. Use parallel assignment for multiple loop variables:
// Reverse a slice for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 { a[i], a[j] = a[j], a[i] }
Note: ++ and -- are statements, not expressions, so they cannot be used in parallel assignment.
Switch
Go's switch is more flexible than C's:
Expressions need not be constants or integers Cases are evaluated top to bottom until a match No automatic fall through (no need for break in each case) Expression-less Switch
If the switch has no expression, it switches on true. This is idiomatic for writing clean if-else-if chains:
// Good: expression-less switch for ranges func unhex(c byte) byte { switch { case '0' <= c && c <= '9': return c - '0' case 'a' <= c && c <= 'f': return c - 'a' + 10 case 'A' <= c && c <= 'F': return c - 'A' + 10 } return 0 }
Comma-Separated Cases
Multiple cases can be combined with commas (no fall through needed):
func shouldEscape(c byte) bool { switch c { case ' ', '?', '&', '=', '#', '+', '%': return true } return false }
Break with Labels
break terminates the switch by default. To break out of an enclosing loop, use a label:
Loop: for n := 0; n < len(src); n += size { switch { case src[n] < sizeOne: break // breaks switch only case src[n] < sizeTwo: if n+1 >= len(src) { break Loop // breaks out of for loop } } }
Type Switch
A type switch discovers the dynamic type of an interface value using .(type):
switch v := value.(type) { case nil: fmt.Println("value is nil") case int: fmt.Printf("integer: %d\n", v) // v is int case string: fmt.Printf("string: %q\n", v) // v is string case bool: fmt.Printf("boolean: %t\n", v) // v is bool default: fmt.Printf("unexpected type %T\n", v) }
It's idiomatic to reuse the variable name (v := value.(type)) since the variable has a different type in each case clause.
When a case lists multiple types (case int, int64:), the variable has the interface type.
The Blank Identifier
The blank identifier _ discards values. It's like writing to /dev/null.
Multiple Assignment
Discard unwanted values from multi-value expressions:
// Only need the error if _, err := os.Stat(path); os.IsNotExist(err) { fmt.Printf("%s does not exist\n", path) }
// Only need the value (discard ok) value := cache[key] // simpler: just use single-value form _, present := cache[key] // when you only need presence check
Never discard errors carelessly:
// Bad: ignoring error will crash if path doesn't exist fi, _ := os.Stat(path) if fi.IsDir() { // nil pointer dereference if path doesn't exist // ... }
Unused Imports and Variables During Development
Silence compiler errors temporarily during active development:
import ( "fmt" "io" )
var _ = fmt.Printf // silence unused import (remove before committing) var _ io.Reader
func main() { fd, _ := os.Open("test.go") _ = fd // silence unused variable }
Import for Side Effect
Import a package only for its init() side effects:
import _ "net/http/pprof" // registers HTTP handlers import _ "image/png" // registers PNG decoder
This makes clear the package is imported only for side effects—it has no usable name in this file.
Interface Compliance Check
Verify at compile time that a type implements an interface:
// Verify that MyType implements io.Writer var _ io.Writer = (MyType)(nil)
// Verify that MyHandler implements http.Handler var _ http.Handler = MyHandler{}
This fails at compile time if the type doesn't implement the interface, catching errors early.
Quick Reference Pattern Go Idiom If initialization if err := f(); err != nil { } Early return Omit else when if body returns Redeclaration := reassigns if same scope + new var C-style for for i := 0; i < n; i++ { } While-style for condition { } Infinite loop for { } Range with key+value for k, v := range m { } Range value only for , v := range slice { } Range key only for k := range m { } Parallel assignment i, j = i+1, j-1 Expression-less switch switch { case cond: } Comma cases case 'a', 'b', 'c': No fallthrough Default behavior (explicit fallthrough if needed) Break from loop in switch break Label Type switch switch v := x.(type) { } Discard value , err := f() Side-effect import import _ "pkg" Interface check var _ Interface = (*Type)(nil) See Also go-style-core: Core Go style principles and formatting go-error-handling: Error handling patterns including guard clauses go-naming: Naming conventions for loop variables and labels go-concurrency: Goroutines, channels, and select statements go-defensive: Defensive programming patterns