Control Flow in Go: if, for, switch
Go's control flow in full — if with an init statement, the only loop (for) in all four forms, switch without break, type switches, and how defer fits in.
What you'll learn
- ✓How Go's if works, including the init statement
- ✓Why Go has one loop keyword and how it covers four forms
- ✓Iterating with range over slices, maps, strings, and channels
- ✓switch without break, multi-case, and a preview of type switches
- ✓How defer schedules cleanup and when it runs
Prerequisites
- •Comfortable with variables, types, and functions — see Variables and Basic Types and Functions in Go
Go has only three control-flow keywords you’ll use daily — if, for, and switch — plus defer, which schedules cleanup. The small surface area is deliberate: there is exactly one way to write a loop in Go, and the language designers wanted it that way. This post covers all of them in full.
if
The basic form is unremarkable:
if x > 0 {
fmt.Println("positive")
} else if x < 0 {
fmt.Println("negative")
} else {
fmt.Println("zero")
}
Two rules to remember from Variables and Basic Types:
- The condition must be a
bool. Integers and strings can’t stand in for truthiness. - Braces are mandatory, even for one-line bodies.
if with an init statement
Go’s distinctive trick is letting you run a short statement before the condition, scoped to the if:
if err := doSomething(); err != nil {
return err
}
err is declared, assigned, and tested in one expression. After the if/else ends, err goes out of scope. This is the pattern you’ll see most often around error handling — it keeps the error variable from leaking into the surrounding function.
You can also use it to compute a value once and reuse it:
if n := len(items); n > 100 {
fmt.Printf("warning: %d items is a lot\n", n)
}
Comparisons
The standard operators: ==, !=, <, <=, >, >=. Boolean logic: &&, ||, !. Both && and || short-circuit, so this is safe:
if p != nil && p.Active {
// p is dereferenced only if non-nil
}
for
for is the only loop in Go. It has four forms, each useful.
1. Three-clause for (the C-style)
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// output:
// 0
// 1
// 2
// 3
// 4
The three clauses are init, condition, and post, separated by semicolons. The init declares variables scoped to the loop.
2. Condition-only for (Go’s while)
Omit the init and post clauses and you have a while loop:
n := 1
for n < 100 {
n *= 2
}
fmt.Println(n) // 128
There is no while keyword in Go. This is it.
3. Infinite for
Omit the condition entirely:
for {
if shouldStop() {
break
}
work()
}
Use break to leave the loop and continue to skip to the next iteration. This is the canonical way to write event loops and worker loops in Go.
4. for with range
range iterates over a collection, yielding index/value pairs. The exact pairs depend on what you iterate.
Over a slice or array:
words := []string{"go", "rust", "python"}
for i, w := range words {
fmt.Printf("%d: %s\n", i, w)
}
// output:
// 0: go
// 1: rust
// 2: python
If you only need the value, discard the index with _:
for _, w := range words {
fmt.Println(w)
}
If you only need the index, omit the value:
for i := range words {
fmt.Println(i)
}
Over a map (covered in Slices and Maps):
ages := map[string]int{"Alice": 30, "Bob": 25}
for name, age := range ages {
fmt.Printf("%s is %d\n", name, age)
}
Map iteration order is deliberately randomised by Go. Never rely on the order.
Over a string, you get byte index and rune:
for i, r := range "héllo" {
fmt.Printf("%d: %c\n", i, r)
}
// output:
// 0: h
// 1: é
// 3: l
// 4: l
// 5: o
The index jumps because é is two bytes in UTF-8.
Over a channel, range receives values until the channel is closed:
for v := range ch {
fmt.Println(v)
}
That’s all four forms. There is no do { ... } while, no Python-style for x in iter. Everything reduces to for.
break and continue
break exits the innermost loop:
for _, n := range nums {
if n < 0 {
break // stop on first negative
}
fmt.Println(n)
}
continue skips to the next iteration:
for _, n := range nums {
if n%2 != 0 {
continue // skip odd numbers
}
fmt.Println(n)
}
Go also has labelled break and continue for jumping out of nested loops:
outer:
for i := 0; i < 5; i++ {
for j := 0; j < 5; j++ {
if i*j > 6 {
break outer
}
}
}
You’ll need labelled breaks rarely — once or twice in most codebases — but they’re cleaner than the alternatives when you do.
Try this. Write a small program that reads a list of integers from os.Args[1:], parses them with strconv.Atoi, and prints the sum of the even numbers only. Use a single for loop with continue to skip odd numbers. If strconv.Atoi returns an error for any argument, print the error and return — don’t silently skip bad input.
switch
switch in Go is more useful than in C-family languages because there is no implicit fall-through. Each case ends automatically — you don’t write break.
switch day {
case "Mon", "Tue", "Wed", "Thu", "Fri":
fmt.Println("weekday")
case "Sat", "Sun":
fmt.Println("weekend")
default:
fmt.Println("not a day")
}
A single case can match multiple values, separated by commas. The default runs if no case matches.
Switch on no value (clean if/else chains)
A switch without an expression is equivalent to switch true. The cases are arbitrary boolean expressions:
switch {
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
case score >= 70:
grade = "C"
default:
grade = "F"
}
This is the idiomatic replacement for long if/else if/else chains.
Switch with an init statement
Like if, switch can run a short statement first:
switch n := rand.Intn(3); n {
case 0:
fmt.Println("zero")
case 1:
fmt.Println("one")
default:
fmt.Println("other:", n)
}
fallthrough if you really want it
The keyword exists for the rare case where you want the next case to run anyway:
switch n {
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("two")
}
// when n == 1: prints both lines
Used very rarely. Most Go code never has a fallthrough in it.
Type switch (a preview)
A switch can also branch on the dynamic type of an interface value:
func describe(v interface{}) {
switch x := v.(type) {
case int:
fmt.Printf("int: %d\n", x)
case string:
fmt.Printf("string: %q\n", x)
case []byte:
fmt.Printf("bytes of length %d\n", len(x))
default:
fmt.Printf("unknown type %T\n", x)
}
}
describe(42)
describe("hello")
// output:
// int: 42
// string: "hello"
We cover interfaces and type assertions properly later in the series. For now, file switch v.(type) as “the way to handle several possible types in one place.”
defer
defer schedules a function call to run when the surrounding function returns. We saw it briefly in Functions in Go; here are the rules in full.
A deferred call runs at function return, no matter how the function exits:
func work() {
defer fmt.Println("cleanup")
fmt.Println("doing work")
}
// output:
// doing work
// cleanup
Multiple defers run in LIFO order — last deferred, first executed:
func main() {
defer fmt.Println("one")
defer fmt.Println("two")
defer fmt.Println("three")
fmt.Println("body")
}
// output:
// body
// three
// two
// one
The arguments to a deferred call are evaluated immediately, but the call itself is delayed:
i := 1
defer fmt.Println("i was", i) // "i was 1" — captures 1 right now
i = 99
// at return, prints: i was 1
This catches everyone at least once.
The classic use is paired-resource cleanup — file close, mutex unlock, HTTP response body close:
func readAll(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
You acquire the resource, immediately defer its release, and stop thinking about cleanup for the rest of the function. This is one of Go’s nicest patterns.
Putting it together
A small program that demonstrates if with an init, a for range, a switch, and a defer:
package main
import (
"fmt"
"os"
"strings"
)
func main() {
defer fmt.Println("done") // runs last
if len(os.Args) < 2 {
fmt.Println("usage: classify <word>...")
return
}
for i, arg := range os.Args[1:] {
word := strings.ToLower(arg)
switch {
case len(word) == 0:
continue
case strings.HasPrefix(word, "go"):
fmt.Printf("%d: %q starts with go\n", i, word)
case len(word) > 8:
fmt.Printf("%d: %q is long\n", i, word)
default:
fmt.Printf("%d: %q is ordinary\n", i, word)
}
}
}
Run it:
go run . golang rust extraordinary hi
# output:
# 0: "golang" starts with go
# 1: "rust" is ordinary
# 2: "extraordinary" is long
# 3: "hi" is ordinary
# done
Every construct in this post is in there.
Try this. Modify the worked example so it also counts how many words start with each letter of the alphabet, and after the loop prints the counts in descending order. You’ll need a map (preview: see Slices and Maps in Go) and a sort — but you can write the counting half using only this post’s tools.
Recap
You now know:
ifrequires aboolcondition and braces; an init statement can declare a variable scoped to theif.foris Go’s only loop, in four forms: three-clause, condition-only, infinite, andrange.rangeyields index/value pairs over slices, key/value over maps, byte-index/rune over strings, and values over channels.switchhas no implicit fall-through; cases can list multiple values;switch { case cond: }replacesif/elsechains; type switches branch on dynamic type.deferschedules cleanup that runs at function return; arguments are evaluated whendeferis executed; defers run in LIFO order.
Next steps
The next post covers Go’s two workhorse collection types — slices and maps — including the shared-backing-array gotcha that catches every Go programmer at least once.
Questions or feedback? Email codeloomdevv@gmail.com.
Related articles
- Go Functions in Go: Declarations, Multiple Returns, and Errors
A complete guide to Go functions — declarations, parameters, multiple and named returns, the error idiom that defines Go, variadic parameters, and functions as values.
- Go Install Go and Write Your First Program
A complete setup guide for Go on macOS, Linux, and Windows — install the toolchain, verify with go version, create a module, and run a real program with go run and go build.
- Go Slices and Maps in Go
A practical guide to Go's two everyday collections — slices and maps. Covers arrays vs slices, make and append, len and cap, the shared-backing-array gotcha, and the comma-ok idiom.
- Go Variables and Basic Types in Go
A practical tour of Go's variable declarations and basic types — var vs :=, int and float64, strings, bool, byte and rune, zero values, type conversion, and constants.