Control flow statements determine the order in which your code executes. Go provides if, switch, and for statements, with a special focus on the important behavior changes in Go 1.23.
Go's if statement is similar to other languages but has some unique features:
Basic if
if x > 0 {
fmt.Println("x is positive")
}
If-else
if x > 0 {
fmt.Println("x is positive")
} else {
fmt.Println("x is not positive")
}
If-else if
if x > 0 {
fmt.Println("positive")
} else if x < 0 {
fmt.Println("negative")
} else {
fmt.Println("zero")
}
If with initialization (Go idiom)
Go allows a short variable declaration or statement before the condition, which is a powerful feature for keeping variable scope limited:
if err := doSomething(); err != nil {
fmt.Println("Error:", err)
return
}
// err is only in scope within the if block
The initialization statement (err := doSomething()) executes first, then the condition (err != nil) is evaluated. This pattern is extremely common in Go for error handling because it:
This is useful because in many languages, you'd write:
result, err := doSomething()
if err != nil {
// handle error
}
// err is still in scope here, could be accidentally reused
In Go, the initialization form keeps err scoped to just the if block, making the code safer and clearer. This is especially valuable in Go's error-handling pattern where you check errors immediately after function calls.
Complete example
package main
import "fmt"
func main() {
x := 10
// Basic if
if x > 5 {
fmt.Println("x is greater than 5")
}
// If with initialization
if y := 20; y > x {
fmt.Printf("%d is greater than %d\n", y, x)
}
// If-else chain
score := 85
if score >= 90 {
fmt.Println("Grade: A")
} else if score >= 80 {
fmt.Println("Grade: B")
} else if score >= 70 {
fmt.Println("Grade: C")
} else {
fmt.Println("Grade: F")
}
}
Go's switch statement offers more flexibility than many languages by removing traditional restrictions. Unlike languages like C, Java, or JavaScript where switch typically requires constant expressions in case labels, a single value per case, and an explicit expression to switch on, Go's switch removes these limitations. This design makes the switch statement more versatile and allows it to elegantly replace long if-else chains.
When Go encounters a switch statement, it evaluates the switch expression (if present) once, then compares it against each case. Cases are evaluated top-to-bottom, and the first matching case executes. Unlike some languages, Go automatically breaks after each case—there's no fallthrough by default—which prevents the common "fall-through bug" found in C-style switches. This automatic breaking makes switch statements safer and more predictable.
Basic switch
The most common form evaluates an expression and matches it against case values:
switch day {
case "Monday":
fmt.Println("Start of week")
case "Friday":
fmt.Println("End of week")
default:
fmt.Println("Midweek")
}
Go evaluates day once, then compares it sequentially against "Monday", "Friday", and so on. If no match is found, the default case executes. The comparison uses the == operator, so switch works with any comparable type—strings, numbers, and other types that support equality comparison.
Switch with multiple cases
Go allows multiple values in a single case, which is more concise than writing separate cases:
switch day {
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday":
fmt.Println("Weekday")
case "Saturday", "Sunday":
fmt.Println("Weekend")
default:
fmt.Println("Invalid day")
}
When Go encounters a case with multiple values, it checks if the switch expression equals any of them. This is equivalent to if day == "Monday" || day == "Tuesday" || ... but more readable and concise. The compiler optimizes this into efficient comparisons, so there's no performance penalty for using multiple values.
Switch with initialization
Go allows a short variable declaration or statement before the switch expression, scoping the variable to the switch block:
switch day := getDay(); day {
case "Monday":
fmt.Println("Monday")
default:
fmt.Println("Other day")
}
// day is not accessible here
The initialization statement (day := getDay()) executes first, then the switch evaluates day. This pattern is useful when you need a value only for the switch statement. The variable scope is limited to the switch block, keeping the code clean and preventing variable pollution in the outer scope. This is the same pattern used in if statements with initialization.
Switch without condition (like if-else chain)
When the switch expression is omitted, Go treats it as switch true, making each case a boolean condition:
switch {
case x < 0:
fmt.Println("Negative")
case x == 0:
fmt.Println("Zero")
case x > 0:
fmt.Println("Positive")
}
Without an expression, Go implicitly uses true as the switch value. Each case must evaluate to a boolean. Go checks each case condition in order, and the first one that evaluates to true executes. This is functionally equivalent to an if-else chain but often more readable, especially with many conditions. The compiler optimizes this similarly to if-else chains, so there's no performance difference.
Switch with fallthrough
Go breaks automatically after each case, but you can explicitly continue to the next case using fallthrough:
switch x {
case 1:
fmt.Println("One")
fallthrough // Continue to next case
case 2:
fmt.Println("Two or One")
default:
fmt.Println("Other")
}
By default, Go inserts an implicit break after each case. When you use fallthrough, execution continues to the next case without checking its condition. This is different from C-style switches where cases fall through by default. In Go, you must explicitly request fallthrough, making the behavior intentional and reducing bugs. Note that fallthrough can only appear as the last statement in a case, and it cannot be used in the last case statement of a switch statement.
Complete example
package main
import "fmt"
func main() {
day := "Monday"
switch day {
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday":
fmt.Println("Weekday")
case "Saturday", "Sunday":
fmt.Println("Weekend")
default:
fmt.Println("Invalid day")
}
// Switch without condition
score := 85
switch {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B")
default:
fmt.Println("C or below")
}
}
Go has only one loop construct: for. This design choice simplifies the language while still providing all the looping capabilities you need. Go's philosophy of simplicity means having one loop construct that can express all looping patterns, rather than separate for, while, and do-while keywords. This reduces cognitive load—you only need to learn one syntax—and makes code more consistent. The compiler optimizes all these patterns similarly, so there's no performance penalty.
The for statement has three components, all of which are optional:
When any component is omitted, Go adapts the behavior accordingly, allowing the same for keyword to handle traditional loops, while-style loops, and infinite loops.
Traditional for loop
This is the classic C-style loop with all three components:
for i := 0; i < 5; i++ {
fmt.Println(i)
}
The execution follows a clear sequence: i := 0 executes once at the start (init), then i < 5 is checked before each iteration (condition). If the condition is true, the loop body executes, followed by i++ after the body (post). Steps 2-4 repeat until the condition becomes false, at which point the loop exits.
While-style loop
When you omit the init and post statements, you get a while-style loop:
i := 0
for i < 5 {
fmt.Println(i)
i++
}
Only the condition is evaluated before each iteration. This is equivalent to while (i < 5) in other languages. You must manage the loop variable yourself (increment i in the body). This pattern is useful when the iteration logic is more complex than a simple increment, giving you full control over how the loop variable changes.
Infinite loop
When you omit all three components, you get an infinite loop:
for {
// Do something
if condition {
break
}
}
With no condition, the loop runs forever until explicitly broken with break or return. This is useful for event loops, servers, or any situation where you want to loop until an external condition changes. The break statement immediately exits the innermost loop, allowing you to escape the infinite loop when needed.
For with range - Supported Data Types
The for range loop works with several data types in Go. Each type returns different values, and knowing what you get from each helps you use range effectively.
1. Arrays and Slices
Arrays and slices return two values: the index and the element at that index. The index is always an int, starting from 0.
numbers := []int{1, 2, 3, 4, 5}
for index, value := range numbers {
fmt.Printf("Index: %d, Value: %d\n", index, value)
}
// Ignore index
for _, value := range numbers {
fmt.Println(value)
}
// Ignore value
for index := range numbers {
fmt.Println(index)
}
You can also range over arrays directly:
arr := [5]int{10, 20, 30, 40, 50}
for i, v := range arr {
fmt.Printf("[%d]=%d\n", i, v)
}
2. Maps
Maps return two values: the key and the value. The iteration order is random—Go randomizes map iteration to prevent programs from depending on a specific order, which helps catch bugs early.
m := map[string]int{"a": 1, "b": 2, "c": 3}
for key, value := range m {
fmt.Printf("Key: %s, Value: %d\n", key, value)
}
// Iterate keys only
for key := range m {
fmt.Println(key)
}
// Iterate values only (Go 1.4+)
for _, value := range m {
fmt.Println(value)
}
3. Strings
When ranging over strings, Go iterates over Unicode code points (runes), not bytes. This is important because a single character might be multiple bytes in UTF-8 encoding. The first value is the byte index where the rune starts, and the second is the rune itself.
str := "Hello"
for index, char := range str {
fmt.Printf("Index: %d, Char: %c (rune: %d)\n", index, char, char)
}
// String with multi-byte characters
str2 := "世界" // Chinese characters
for i, r := range str2 {
fmt.Printf("Byte index: %d, Rune: %c\n", i, r)
}
Note that the index represents the byte position, not the character position. For multi-byte characters, the index will jump by more than 1.
4. Channels
Ranging over channels receives values until the channel is closed. The loop automatically exits when the channel is closed. This is a common pattern for consuming values from a channel.
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
// Range receives values until channel is closed
for value := range ch {
fmt.Println(value) // Prints: 1, 2, 3
}
// Loop exits automatically when channel is closed
If you range over a channel that's never closed, the loop will block forever waiting for more values. Always close channels when you're done sending, or use a context or done channel to signal completion.
5. Integers (Go 1.22+)
Starting in Go 1.22, you can range over integers. This iterates from 0 up to (but not including) the integer value. This is useful for simple counting loops.
// Range over integer (Go 1.22+)
for i := range 5 {
fmt.Println(i) // Prints: 0, 1, 2, 3, 4
}
// Equivalent to: for i := 0; i < 5; i++
Summary of range return values
(index int, element T) where T is the element type(key K, value V) where K is the key type and V is the value type(index int, rune rune) where index is byte position and rune is the Unicode code point(value T) where T is the channel's element type (only one value, no index)(index int) where index ranges from 0 to n-1 (Go 1.22+)Important considerations
_ to ignore any return value you don't needComplete example
package main
import "fmt"
func main() {
// Traditional for
fmt.Println("Traditional for:")
for i := 0; i < 5; i++ {
fmt.Print(i, " ")
}
fmt.Println()
// While-style
fmt.Println("While-style:")
i := 0
for i < 5 {
fmt.Print(i, " ")
i++
}
fmt.Println()
// Range over slice
fmt.Println("Range over slice:")
numbers := []int{10, 20, 30, 40, 50}
for idx, val := range numbers {
fmt.Printf("[%d]=%d ", idx, val)
}
fmt.Println()
}
⚠️ Critical Behavior Change in Go 1.23 The
for rangeloop variable scoping has been fixed in Go 1.23. This is one of the most significant behavior changes and affects how you write concurrent code with range loops.
The Fix (Go 1.23)
In Go 1.23, each iteration gets its own copy of the loop variable:
// CORRECT BEHAVIOR (Go 1.23)
var funcs []func()
for i := 0; i < 3; i++ {
funcs = append(funcs, func() {
fmt.Println(i) // Each closure captures its OWN copy of i
})
}
// When called, functions print correct values
for _, f := range funcs {
f() // Prints: 0, 1, 2 (CORRECT!)
}
Practical example showing the difference
package main
import (
"fmt"
"time"
)
func main() {
// Example 1: Range over slice (Go 1.23 behavior)
numbers := []int{1, 2, 3}
var funcs []func()
for _, num := range numbers {
funcs = append(funcs, func() {
fmt.Println(num) // Each closure gets its own copy
})
}
fmt.Println("Range over slice:")
for _, f := range funcs {
f() // Prints: 1, 2, 3 (correct in Go 1.23)
}
// Example 2: Traditional for loop (always worked correctly)
var funcs2 []func()
for i := 0; i < 3; i++ {
num := i // Create local copy
funcs2 = append(funcs2, func() {
fmt.Println(num)
})
}
fmt.Println("Traditional for:")
for _, f := range funcs2 {
f() // Prints: 0, 1, 2
}
// Example 3: Common pattern with goroutines (now safe in Go 1.23)
fmt.Println("With goroutines:")
for i, num := range numbers {
go func() {
fmt.Printf("Goroutine %d: value %d\n", i, num)
}()
}
time.Sleep(100 * time.Millisecond)
// In Go 1.23, each goroutine gets correct values
}
Migration guide
If you have code that relied on the old behavior (which was typically buggy), you may need to update it:
// OLD CODE (Go 1.22) - might have worked by accident
for _, item := range items {
go process(item) // Might have issues
}
// NEW CODE (Go 1.23) - works correctly
for _, item := range items {
item := item // Explicit copy if needed (though not necessary now)
go process(item)
}
// Or simply (Go 1.23):
for _, item := range items {
go process(item) // Now safe!
}
Important To use the new range loop behavior in Go 1.23, ensure your go.mod file specifies go 1.23 or higher. If your project uses Go modules (indicated by the presence of a go.mod file), the Go version specified in that file determines which language features are available.
Note For a detailed explanation of why loop variable capture occurred in earlier Go versions and how closure capture works, see Loop Variable Capture in Closures in Chapter 6.
Note (Go 1.23) Range-over-function iterators were introduced experimentally in Go 1.22 and stabilized in Go 1.24. This feature allows you to create custom iterator functions that work seamlessly with
for...rangeloops, extending iteration beyond built-in types.
Go's for...range loops traditionally work with built-in types: arrays, slices, maps, strings, and channels. Range-over-function iterators extend this capability, allowing you to define custom iteration logic for any data structure or pattern.
How range-over-function iterators work
An iterator function is a method or function that takes a yield callback function as its parameter. The yield function is called for each value to be iterated, and it returns a boolean indicating whether iteration should continue (true) or stop (false).
The iterator function signature follows one of these patterns:
func(yield func() bool) - for iterators with no valuesfunc(yield func(V) bool) - for iterators with a single valuefunc(yield func(K, V) bool) - for iterators with key-value pairsBasic example
package main
import "fmt"
type MyCollection []string
// Define an iterator method
func (m MyCollection) All(yield func(int, string) bool) {
for i, v := range m {
if !yield(i, v) {
return // Stop iteration if yield returns false
}
}
}
func main() {
collection := MyCollection{"apple", "banana", "cherry"}
// Use the iterator with range
for i, v := range collection.All {
fmt.Printf("%d: %s\n", i, v)
}
}
Output:
0: apple
1: banana
2: cherry
Iterator with early termination
The yield function returns false when the loop breaks, allowing the iterator to clean up resources or stop early:
package main
import "fmt"
type Numbers []int
func (n Numbers) All(yield func(int) bool) {
for _, v := range n {
if !yield(v) {
fmt.Println("Iteration stopped early")
return
}
}
}
func main() {
numbers := Numbers{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
for num := range numbers.All {
if num > 5 {
break // This causes yield to return false
}
fmt.Println(num)
}
}
Using standard library iterator functions
Go 1.23+ includes iterator functions in the slices and maps packages:
package main
import (
"fmt"
"slices"
)
func main() {
// Using slices.All iterator
data := []int{10, 20, 30, 40, 50}
for i, v := range slices.All(data) {
fmt.Printf("Index %d: %d\n", i, v)
}
// Using slices.Values (values only, no index)
for v := range slices.Values(data) {
fmt.Println(v)
}
// Using slices.Backward
for i, v := range slices.Backward(data) {
fmt.Printf("Reverse index %d: %d\n", i, v)
}
}
Map iterators
package main
import (
"fmt"
"maps"
)
func main() {
scores := map[string]int{
"Alice": 95,
"Bob": 87,
"Carol": 92,
}
// Iterate over keys
for key := range maps.Keys(scores) {
fmt.Println("Key:", key)
}
// Iterate over values
for value := range maps.Values(scores) {
fmt.Println("Value:", value)
}
// Iterate over key-value pairs
for key, value := range maps.All(scores) {
fmt.Printf("%s: %d\n", key, value)
}
}
Custom iterator with filtering
You can create iterators that implement custom logic, such as filtering:
package main
import "fmt"
type Numbers []int
// Iterator that yields only even numbers
func (n Numbers) Evens(yield func(int) bool) {
for _, v := range n {
if v%2 == 0 {
if !yield(v) {
return
}
}
}
}
// Iterator that yields numbers in a range
func (n Numbers) InRange(min, max int) func(func(int) bool) {
return func(yield func(int) bool) {
for _, v := range n {
if v >= min && v <= max {
if !yield(v) {
return
}
}
}
}
}
func main() {
numbers := Numbers{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// Iterate over even numbers
for num := range numbers.Evens {
fmt.Println("Even:", num)
}
// Iterate over numbers in range
for num := range numbers.InRange(3, 7) {
fmt.Println("In range:", num)
}
}
Iterator best practices
All, Evens, Keys, Values)yield and return early if it's falseslices.All, maps.Keys, etc., when availableWhen to use iterators
Range-over-function iterators provide a powerful, idiomatic way to extend Go's iteration capabilities while maintaining the simplicity and readability that Go is known for.
Range loop examples
package main
import "fmt"
func main() {
// Range over slice
fruits := []string{"apple", "banana", "cherry"}
for index, fruit := range fruits {
fmt.Printf("%d: %s\n", index, fruit)
}
// Range over map
scores := map[string]int{
"Alice": 95,
"Bob": 87,
"Carol": 92,
}
for name, score := range scores {
fmt.Printf("%s: %d\n", name, score)
}
// Range over string (iterates over runes)
text := "Hello, 世界"
for i, char := range text {
fmt.Printf("Index %d: %c (U+%04X)\n", i, char, char)
}
// Range over channel (covered in Chapter 6: Concurrency in Go)
// ch := make(chan int)
// for value := range ch {
// fmt.Println(value)
// }
}
Break
The break statement immediately exits the innermost loop, transferring control to the statement immediately following the loop. This is useful when you've found what you're looking for or encountered a condition that makes further iteration unnecessary.
When break executes, the loop terminates completely—no more iterations occur, and execution continues with the code after the loop. In nested loops, break only affects the innermost loop containing it. If you need to exit multiple levels of nesting, you'll need to use labeled break (discussed below).
Common use cases for break include:
for i := 0; i < 10; i++ {
if i == 5 {
break // Exit loop immediately when i is 5
}
fmt.Println(i) // Prints 0-4, then loop exits
}
// Execution continues here after break
Continue
The continue statement skips the remainder of the current loop iteration and immediately proceeds to the next iteration. Unlike break, which exits the loop entirely, continue only skips the current iteration—the loop continues with the next value.
When continue executes, any code remaining in the loop body after the continue statement is skipped, and the loop proceeds to evaluate its condition for the next iteration. In a for loop with a post statement, the post statement still executes before the next iteration begins.
Common use cases for continue include:
for i := 0; i < 10; i++ {
if i%2 == 0 {
continue // Skip the rest of this iteration for even numbers
}
fmt.Println(i) // Only executes for odd numbers: 1, 3, 5, 7, 9
}
Key differences
break exits the loop completely; continue skips to the next iterationbreak stops all further iterations; continue only skips the current onebreak, execution continues after the loop; after continue, execution continues at the start of the next iterationLabeled break and continue
By default, break and continue affect only the innermost loop. When you have nested loops, sometimes you need to break out of or continue an outer loop from within an inner loop. Go provides labeled break and continue statements for this purpose.
Labels are identifiers followed by a colon (:) placed before a statement (typically a loop). You can then use break label or continue label to control which loop the statement affects. The label must be on the same level or an outer level relative to where you use it—you cannot break or continue to an inner loop.
When to use labeled break
Labeled break is useful when you're searching through nested data structures and want to exit all loops once you find what you're looking for. Without labels, you'd need to use flags or other workarounds to exit nested loops.
outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i*j == 4 {
break outer // Breaks out of the outer loop, not just the inner one
}
fmt.Printf("i=%d, j=%d\n", i, j)
}
}
// Execution continues here after break outer
When to use labeled continue
Labeled continue skips to the next iteration of the labeled loop, which is useful when you want to skip the current iteration of an outer loop based on a condition in an inner loop.
outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if j == 1 {
continue outer // Skip to next iteration of outer loop
}
fmt.Printf("i=%d, j=%d\n", i, j)
}
}
Important points
outer, inner, loop, etc.)Complete example
package main
import "fmt"
func main() {
// Break example
fmt.Println("Break example:")
for i := 0; i < 10; i++ {
if i == 5 {
break
}
fmt.Print(i, " ")
}
fmt.Println()
// Continue example
fmt.Println("Continue example:")
for i := 0; i < 10; i++ {
if i%2 == 0 {
continue
}
fmt.Print(i, " ")
}
fmt.Println()
// Labeled break
fmt.Println("Labeled break:")
outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == 1 && j == 1 {
break outer
}
fmt.Printf("(%d,%d) ", i, j)
}
}
fmt.Println()
}
Key points about control flow
for loops (no while or do-while)if can include initialization statementsswitch is more flexible than in many languagesfor range loop variable scoping issuebreak and continue to control loop flow